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:
- A webhook URL unique to this app.
- 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:
{
"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: truefor an already-licensed shop changes nothing and bills nothing. - Order does not matter. An event with an older
occurred_atthan 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_idand a currentoccurred_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
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
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
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:
Option A: Marketplace API (recommended)¶
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.
Related¶
- Marketplace Distribution: the general guide for selling through any third-party marketplace
- Offer Multiple Purchase Options: mix Shopify with Stripe or other channels on one buy page
- API Reference: full endpoint documentation
- Post-Sale Customer Support: resend delivery emails, extend licences, reset devices