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

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

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

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.