Skip to content

How To: Sell on Shopify

PyLocket supports two distinct ways of selling a protected app to Shopify merchants. They use different machinery, so pick the section that matches where your customers buy:

Where the customer buys What drives licensing Section
The Shopify App Store (your product is a Shopify app with a subscription) The Partner Entitlement Webhook: your integration forwards Shopify subscription events, and PyLocket starts and stops the licence in step Selling on the Shopify App Store
A regular Shopify storefront (your own shop selling the download) The Marketplace API or redemption codes, exactly like Gumroad or Paddle Selling on your own Shopify storefront

Selling on the Shopify App Store

Apps distributed through the Shopify App Store must bill merchants through Shopify, so the subscription lives inside Shopify and PyLocket cannot see it directly. The bridge is the Partner Entitlement Webhook: a small service you run (typically the same backend that receives your Shopify webhooks) verifies each Shopify webhook and forwards a normalized entitlement decision to PyLocket. PyLocket then grants, revokes, or reactivates the merchant's licence to match.

Shopify ── webhook ──> your service ── POST ──> PyLocket ──> licence
                       (verifies the            grants /
                        Shopify HMAC)           revokes

PyLocket never talks to Shopify. Your service is the only caller.

Step 1: Enable the webhook in the portal

On your app's page in the portal, open Sales, find Sell on the Shopify App Store, enter your product key (the app value your service will send, for example your-app-slug), and click Enable webhook. Requires a Pro subscription.

You get two things:

  1. A webhook URL unique to this app.
  2. A shared secret, shown once. Save it immediately and store it as an encrypted secret on your side. If you lose it, rotate it from the same card; after a rotation the old secret keeps working for 24 hours so you can update your service without downtime.

Step 2: Send entitlement events

POST JSON to the webhook URL with the secret in a header:

POST <your webhook URL>
Content-Type: application/json
X-MerchantOps-Secret: <the shared secret>
{
  "app": "your-app-slug",
  "shop": "example.myshopify.com",
  "topic": "app_subscriptions/update",
  "status": "active",
  "entitled": true,
  "subscription_name": "Pro",
  "event_id": "a-unique-id-per-event",
  "occurred_at": "2026-08-16T10:00:00Z",
  "trial": false,
  "test": false,
  "customer_email": "merchant@example.com"
}
Field Required Meaning
app Yes Must equal the product key configured in the portal
shop Yes The merchant's permanent *.myshopify.com domain. This is the licence identity. Always send this domain, never a custom domain
topic No The originating Shopify topic, for the audit trail
status No Shopify's status, for the audit trail. PyLocket never derives the decision from it
entitled Yes The decision. true means the licence should be active; false ends it. Must be a JSON boolean
subscription_name No Plan name, stored for display
event_id Yes Unique per event. PyLocket deduplicates on it, so retries are always safe
occurred_at Yes Shopify's event timestamp (ISO 8601). Out-of-order protection keys on it
trial No true while the subscription is in its free trial. Trial licences carry no platform fee; the fee is charged once, when the first non-trial entitled: true arrives (the conversion). A trial licence follows the subscription: any entitled: false ends it, exactly like a paid licence
test No true for Shopify test subscriptions. The full flow runs (so you can test end to end) but nothing is ever billed
customer_email No When present, PyLocket also sends its standard delivery email with the download link and licence key

What PyLocket does with each event

Situation Result (action in the response)
entitled: true, no licence for this shop yet granted: a licence is minted and the response carries license_key and a durable delivery_url
entitled: true, licence already active noop: nothing changes, no second licence, no second fee. Shopify re-delivers subscription webhooks every billing cycle, so this is normal traffic
entitled: true, licence was revoked or expired reactivated: the same licence, under the same key, becomes active again. Installed apps recover automatically on their next launch
entitled: false, licence active revoked: the licence ends and active devices are deactivated. This includes a licence in a subscription trial (the response carries note: "trial_ended"; a trial minted by a test: true event carries no trial state, so its revoke has no note). The one exception is a no-commitment trial from the /trial endpoint below: it keeps running (noop, note: "trial_unaffected_by_store")
entitled: false, no licence exists for this shop noop: recorded as a processed "not entitled" statement, so a grant that arrives out of order afterwards cannot resurrect the shop
Same event_id again duplicate: the original outcome is echoed back, including the key and delivery URL for grants, so retries are self-healing
Event older than one already processed for this shop stale_skipped: recorded, not applied. On an exact timestamp tie, the revoke wins
Wrong app value unactionable: recorded and acknowledged so your retry loop stops. Check the product key configured in the portal

Every response is:

{
  "ok": true,
  "action": "granted",
  "event_id": "a-unique-id-per-event",
  "license_key": "XXXX-XXXX-XXXX-XXXX",
  "delivery_url": "https://get.pylocket.com/d/...",
  "note": null
}

license_key and delivery_url are present on grant-type results (granted, reactivated, noop, and duplicates of those). The delivery_url is a permanent hosted download page for that merchant. It offers the build your app currently delivers for each platform, including a build you released since the merchant's last Shopify event. It does not show the key, so show license_key in your app too, and surface delivery_url in your app's admin UI so the merchant can come back to it anytime.

Downloads through the page and the PyLocket Installer share one allowance: 10 for each build offered to that merchant, pooled across builds. A build counts as offered if it was current when Shopify notified PyLocket or when the merchant downloaded it, or if it is current now. The link itself never expires while the licence is active. If a merchant uses up the allowance, raise that licence's download limit on the Licenses page of the portal to a number above the one it shows: a limit you set replaces the allowance when it is higher, it does not add to it.

Return-code contract

PyLocket returns 2xx only after the change is durably saved. Treat any 5xx or timeout as retryable. Treat 400, 401, and 404 as permanent errors and alert on them: they mean a wiring problem (bad payload, wrong secret, wrong URL), and retrying will not fix them.

Idempotency and ordering, precisely

  • Retries are safe. Duplicate event_ids are detected and answered with the original outcome.
  • Repeats are safe. A fresh entitled: true for an already-licensed shop changes nothing and bills nothing.
  • Order does not matter. An event with an older occurred_at than one already processed for that shop is skipped. If a grant and a revoke carry the exact same timestamp, the revoke wins.
  • Re-assertions are welcome. A periodic reconciliation pass on your side that re-sends each shop's current state (with a fresh event_id and a current occurred_at) is the recommended guard against missed webhooks, and resolves idempotently.

Billing

The standard PyLocket licence fee is charged once per merchant, at purchase (or at trial conversion). Cancels followed by resubscribes reactivate the existing licence and are never billed again. Trial and test events are never billed.

Optional event fields

Events may additionally carry source, subscription_gid, shop_gid, order_gid, and order_number. They are stored on the licence as attribution metadata (so a licence can be tied back to the specific Shopify charge that produced it) and never affect the entitlement decision. They are handled leniently: an empty string means absent, and a malformed value is ignored rather than rejected, so adding or fixing them can never break a live event.

Broker companion endpoints

Five sibling endpoints share the webhook's URL token and secret. They are meant to be called by your broker, after it has verified that the calling client actually controls the claimed shop (for example by resolving the shop from a Shopify access token). Unlike the webhook, they are synchronous calls: failures return honest 4xx responses that your side should treat as permanent and alert on.

Read a shop's entitlement

GET <webhook URL>/entitlement?shop=example.myshopify.com

Returns {ok, shop, entitled, status, trial, license_key, delivery_url, expires_at}. Use it to render the licence key and download link inside your own app; it is strictly read-only.

Set or clear a delivery email

POST <webhook URL>/delivery-email
{"shop": "example.myshopify.com", "email": "merchant@example.com"}

Stores a merchant-provided address on the shop's licence and sends the standard PyLocket delivery email (download link plus licence key). Sending "email": null clears the address. Setting the same, already-delivered address again is acknowledged without a resend. The address should be typed by the merchant, never read from Shopify on their behalf.

Start a no-commitment trial

POST <webhook URL>/trial
{"shop": "example.myshopify.com", "event_id": "trial:example:2026-08-17",
 "occurred_at": "2026-08-17T10:00:00Z"}

Mints a 14-day trial licence for a shop with no subscription behind it, or echoes the shop's current licence state when one already exists — the call is idempotent by state, safe to make on every sign-in with any (or no) event id.

The shop is only the licence's identity. Everything about the trial is bound to the end user's computer, not the store: the 14-day clock starts when the app first runs on a machine (until then the licence reports expires_at: null), each computer can run a limited number of trials of the app regardless of shop, email, or account (default one, developer- adjustable in the portal), and store events never end this kind of trial — uninstalling the Shopify app leaves it running out its own clock, so reinstalling simply resumes it under the same key. A trial that comes through the webhook (entitled: true with trial: true) is different: it is the subscription's trial, it has no clock of its own, and any entitled: false ends it. A /trial licence that later receives a trial: true webhook event becomes that kind of trial. A converted (paying) trial frees the machine, so an agency that pays for one store can still trial another. The 14-day expiry is enforced server-side at activation and refresh. The trial is never billed; the fee fires once at conversion — the first entitled: true webhook event without the trial flag — which also lifts the trial's expiry so a merchant who subscribes on day 6 is not cut off on day 14.

Honour Shopify's shop/redact

POST <webhook URL>/redact
{"shop": "example.myshopify.com", "app": "your-product-key"}

Removes the shop domain, any email and every Shopify attribution field from the shop's licence, and deletes its delivery links and event history. The licence row itself stays, anonymised. It becomes expired, so an installed copy asks for a key instead of locking up; that includes a subscription trial, which has no clock of its own once the shop is gone. The one exception is an active no-commitment trial from the /trial endpoint, which keeps running its own clock. Device fingerprint hashes are kept because they enforce one free trial per computer. The response lists what was removed and what was kept. A shop that is still paying, or is in a live subscription trial, is refused with 409 unless you send "force": true. A redact cannot be undone.

Reset a merchant's computers

POST <webhook URL>/reset-devices
{"shop": "example.myshopify.com", "app": "your-product-key"}

Deactivates every computer on the shop's licence so the merchant can move to a new one. Each licence gets 3 resets in any 30 days, separate from the resets you do in the portal; the response carries resets_remaining and next_reset_available_at, and a fourth reset returns 429 with a Retry-After header. A reset never gives a computer that already used a free trial a second one.

app is required on these two endpoints and a mismatch returns 409. The three endpoints above accept it too: the trial endpoint and the entitlement read (where app is a query parameter) refuse a mismatch with 409; the delivery-email endpoint currently accepts a mismatch and records it.


Selling on your own Shopify storefront

A regular Shopify shop selling your app as a product works like every other external marketplace: your storefront collects the money, and your backend provisions a licence through PyLocket. Two options:

Subscribe to Shopify's orders/paid webhook on your store. When it fires, call the Marketplace API with channel: "shopify" and the order's customer email:

curl -X POST https://api.pylocket.com/v1/marketplace/licenses \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "app_id": "YOUR-APP-ID",
    "channel": "shopify",
    "customer_email": "customer@example.com",
    "marketplace_order_id": "shopify-order-1234"
  }'

The response carries the licence key and a download URL. Deliver them to the customer from your order-confirmation flow (or rely on PyLocket's delivery email, which is sent when customer_email is provided and a delivery surface exists).

For refunds, subscribe to refunds/create and call POST /v1/licenses/{license_id}/revoke.

Option B: Redemption codes

Generate a batch of codes in the portal, attach one code per purchase using a Shopify digital-delivery app, and customers redeem at your branded redemption page. See Marketplace Distribution for the full walkthrough. Codes are free to create; the licence fee applies when a code is redeemed.