Skip to main content

Billing through Shopify

An app published on the Shopify App Store has to bill its merchants through a billing solution Shopify provides. The Shopify driver does that: it opens subscriptions at Shopify through the Admin GraphQL API, the merchant approves them in the Shopify admin, and the package learns their state from Shopify's webhooks. Plans, entitlements and the notices about them work as they do on every other driver.

Shopify runs the cycle. It charges the merchant on their Shopify invoice, collects the tax that applies, and pays the app's share to your partner account. So the package issues no invoice, computes no tax and runs no dunning of its own here, and it never sees a payment method.

Setting it up​

Select the driver and give it the client secret of your Shopify app, which Shopify signs every webhook with:

BILLING_DRIVER=shopify
BILLING_SHOPIFY_CLIENT_SECRET=your-client-secret

The billing owner is the shop. Point billing.customer.model at the model your app keeps a shop in, and billing.customer.column at the column that holds its permanent *.myshopify.com domain. That domain is the owner's customer reference, so a webhook finds the owner by it, and it is the host of every request the driver makes for the owner.

Your app holds an offline access token for each shop that installed it. Tell the package where it is by binding ShopifyAccessTokens in a service provider of yours:

use App\Models\Shop;
use Pushery\Billing\Contracts\ShopifyAccessTokens;

$this->app->bind(ShopifyAccessTokens::class, fn (): ShopifyAccessTokens => new class implements ShopifyAccessTokens
{
public function for(string $shop): string
{
$token = Shop::query()->where('domain', $shop)->value('access_token');

return is_string($token) ? $token : throw new RuntimeException("No access token for {$shop}.");
}
});

Subscribe the topics app_subscriptions/update, app_purchases_one_time/update and app/uninstalled to the package's webhook path, for instance in the shopify.app.toml of your app:

[[webhooks.subscriptions]]
topics = ["app_subscriptions/update", "app_purchases_one_time/update", "app/uninstalled"]
uri = "https://your-app.example/billing/webhook"

Opening a subscription​

Checkout::subscribe() opens the subscription at Shopify and returns where the merchant approves it:

use Pushery\Billing\Contracts\Checkout;

$intent = app(Checkout::class)->subscribe($shop, 'pro');

$url = $intent->payload['checkout_url'];

Send the merchant to that page. Inside the Shopify admin your app runs in a frame, and the admin does not show its confirmation page inside it, so open the page in the top frame, for instance with window.open(url, '_top'). The account hub's plan screen does this itself when it runs in a frame. Once the merchant approves, Shopify returns them to billing.checkout.success_url.

The price is the tier's price_display, never a value from the request. Shopify bills every 30 days or every year, so a tier sold here has the interval month or year, and any other interval is refused. The trial is the one billing.trial grants, once per owner, and Shopify counts it from the moment the subscription was created.

Shopify keeps one subscription per installation of an app. A second contract type beside it is refused, and so is a caller reference, because a Shopify subscription carries no data of the app. A coupon code is not applied: Shopify discounts a subscription only as part of its price.

Selling an add-on​

OneTimeCharge::purchase() opens a one-time purchase at Shopify for the add-on's price_display and returns where the merchant approves it, the same way a subscription is opened. Once Shopify reports the purchase active, the package records it as an add-on purchase, and whatever the add-on grants follows from there, prepaid units that do not expire among them: the package keeps those itself, and Shopify only collects the purchase. The declarations and the caller reference you pass come back on the AddonPurchased event. A tip routes money to a merchant, which Shopify's billing cannot do, so it is refused.

Usage​

The driver reports no usage to Shopify. A tier whose meters price usage past the allowance, with a unit_price or a provider_price, would have its units counted and never charged, so Checkout::subscribe() refuses it with NotPossibleOnShopify. A meter that only counts against an allowance, with no price, works as everywhere. Sell units in advance instead, as an add-on whose purchase grants them: the package keeps prepaid units itself, and they roll over from one period to the next until they are used.

How the state arrives​

Each app_subscriptions/update names the subscription that moved, and each app_purchases_one_time/update the purchase. The driver reads the state back from the shop and reports a subscription's as the package's own:

ShopifyThe package
ACTIVEtrialing while the trial runs, then active
FROZENpast_due, held by Shopify until the merchant pays
canceledended, or grace until the end a cancellation of yours set

A subscription the merchant approves replaces the one they had, and Shopify cancels the old one. That cancellation is reported as nothing at all, so changing a plan never takes it away in between. A subscription that never ran is reported as nothing too: one that waits for the merchant's approval (PENDING, or the deprecated ACCEPTED), and one they declined or let expire (DECLINED, EXPIRED). It gave the merchant nothing, and a plan change opens it beside the subscription they keep. A subscription your app did not open through the package is ignored.

When a merchant removes the app, Shopify cancels its subscriptions and sends app/uninstalled, and nothing else: no app_subscriptions/update arrives for that cancellation, and the shop no longer answers the app. So app/uninstalled ends every subscription of the shop at once, without asking the shop. Its body is the shop, the same body shop/update sends, so the driver tells the two apart by the X-Shopify-Topic header and reads the shop from the signed body. billing:webhooks:replay rebuilds a delivery from its stored body, without that header, so a replayed uninstall ends nothing. Shopify's own retries carry the header.

billing:sync reads the active subscription of each owner back the same way. Shopify lists only the active ones, so where an installation reports none, the sync reads the owner's latest subscription back by its id: a cancellation that no webhook reported, after an outage of your endpoint for one, ends there as well.

Canceling and changing a plan​

Shopify cancels a subscription at once. So SubscriptionActions::cancel() cancels it at Shopify without a credit, since the merchant paid for the period in progress, and keeps the plan until that period ends. Shopify reports nothing when that end arrives, so the hourly billing:run ends the subscription then and takes the plan back. cancelAt() does the same to a moment inside the period, and cancelNow() ends it at once.

A plan changes, and a canceled subscription starts again, only through a new subscription the merchant approves. swap() and resume() cannot hand back the page where the merchant does, so they refuse with NotPossibleOnShopify. Open the new subscription with Checkout::subscribe() instead; Shopify replaces the current one once it is approved.

The account hub reads this from the driver, whose capabilities report supportsInPlaceSubscriptionChanges as false. Its plan screen opens the checkout for every plan change, a downgrade included, and schedules nothing for the end of the period. Its preview of a change shows no figure, since Shopify shows the price on the page where the merchant approves it. After a cancellation, the subscription screen and the banner offer the plans instead of a resume.

Testing with a development store​

Set BILLING_SHOPIFY_TEST=true and the driver opens every subscription and purchase as a test charge, which a development store approves without paying. Leave it unset in production: a live store charged in test mode pays nothing.