Skip to content

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:

Authorization: Bearer <ACCESS_TOKEN>

Obtain one from POST /v1/auth/login.

2. API key for machine-to-machine integrations (marketplace webhooks, scripts, CI):

X-API-Key: <YOUR_API_KEY>

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/rotate
  • POST /v1/auth/2fa/setup, /2fa/confirm, /2fa/disable
  • POST /v1/auth/cli/authorize
  • POST /v1/billing/cancel, POST /v1/billing/portal, and the Stripe Connect endpoints (GET and DELETE /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:

{
  "email": "dev@example.com",
  "password": "SecureP@ss123",
  "company_name": "My Company"
}

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:

{
  "email": "dev@example.com",
  "password": "SecureP@ss123"
}

Response (200) — without 2FA:

{
  "access_token": "eyJhbGc...",
  "refresh_token": "eyJhbGc...",
  "token_type": "bearer",
  "expires_in": 3600
}

Response (200) — with 2FA:

{
  "mfa_required": true,
  "mfa_token": "mfa_abc123"
}

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:

{
  "mfa_token": "mfa_abc123",
  "code": "123456"
}

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:

{
  "refresh_token": "eyJhbGc..."
}

Response (200):

{
  "access_token": "eyJhbGc...",
  "token_type": "bearer",
  "expires_in": 3600
}

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:

{
  "code": "123456"
}

Response (200):

{
  "two_factor_enabled": true
}

POST /v1/auth/2fa/disable

Disable 2FA.

Request Body:

{
  "code": "123456"
}

GET /v1/auth/2fa/status

Check 2FA status.

Response (200):

{
  "two_factor_enabled": true,
  "backup_codes_remaining": 3
}

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:

{
  "name": "MyApp",
  "platform_targets": ["win-x64", "linux-x64", "macos-arm64"]
}

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:

{
  "name": "MyApp Pro",
  "description": "Updated description"
}

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:

{
  "delivery_mode": "released_only",
  "release_current": true
}
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:

  • 404 if the app is not yours.
  • 409 with "reason": "nothing_released" when switching to released_only with no released build and without release_current: every platform would have nothing to download. Release a build first, or send "release_current": true.
  • 409 with "reason": "push_target_not_released" when switching to released_only while your active update push names an unreleased build, without release_current. Release that build, send "release_current": true, or clear the push with DELETE /v1/apps/{app_id}/push-update.
  • 422 for an unknown mode or field.
  • 429 as 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.

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):

{
  "download_url": "https://cdn.pylocket.com/...",
  "expires_at": "2026-03-08T00:00:00Z"
}

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:

  • 404 if the app is not yours or the build does not belong to it.
  • 409 with "reason": "build_not_servable" if the build is not READY or has no protected artifact.
  • 429 after 30 release, unrelease and delivery-mode calls in a minute (the three share the limit), with Retry-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:

  • 404 and 429 as for release.
  • 409 with "reason": "active_push_target" if the app is in released_only and the build is the target of the app's active update push. Clear the push first with DELETE /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):

{
  "checkout_url": "https://checkout.stripe.com/c/pay/cs_live_..."
}

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.txt beside the executable (simplest for automation): write a file named license-key.txt containing the key in the same folder as the .exe (or, for a macOS .app, next to the inner Contents/MacOS/<exe>). The runtime reads it automatically — no prompt.
  • PYLOCKET_LICENSE_KEY environment variable set to the key before launch.
  • The PyLocket Installer / delivery page (get.pylocket.com/download), which injects license-key.txt for 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:

{
  "code": "PLR-XXXXXXXX-XXXX",
  "customer_email": "buyer@example.com"
}

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 /trial endpoint 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_error for 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_error and data_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 example nothing_released or build_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 the X-Request-ID header; 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:

X-RateLimit-Limit: <limit>
X-RateLimit-Remaining: <remaining>
X-RateLimit-Reset: <unix-timestamp>

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:

{
  "items": [...],
  "total": 150,
  "limit": 20,
  "offset": 0
}

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