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) |
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 |
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
always shows the licence's current key, so surface it in your Shopify app's
admin UI and the merchant can come back to it anytime.
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¶
Three 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 a trial —
uninstalling the Shopify app leaves the trial running out its own clock,
so reinstalling simply resumes it under the same key. 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.
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