The Shopify integration automatically creates promotion codes when customers purchase book products on Shopify. Based on the product purchased, a time-limited 100% discount promotion is created and emailed to the customer.
| Tier | Products | Free Period |
|---|---|---|
SINGLE_VOLUME |
Any single POSB, EW, or Handbook volume | 1 Month (30 days) |
BUNDLE |
Any POSB or EW bundle | 3 Months (90 days) |
OT_NT_SET |
Any OT or NT set (POSB, EW) | 6 Months (180 days) |
FULL_SET |
Any full set (POSB, EW) | 12 Months (360 days) |
orders/paid webhook to /api/shopify/webhookIf a user already has an active 100% discount (from a previous Shopify purchase) and applies another promotion code, the free period is extended by adding the new duration to the existing end date.
POST /api/shopify/products/import and assign each a tier via PUT /api/shopify/products/:idShopify Purchase Free Access)| Variable | Description | Example |
|---|---|---|
SHOPIFY_WEBHOOK_SECRET |
HMAC signing secret from Shopify webhook configuration | shpsec_abc123... |
CLIENT_BASE_URL |
Base URL of the client app (for registration links in emails) | https://digital.lmw.org |
Shopify promotions are time-limited free-access grants tied to a Shopify order ID. They are applied differently depending on whether the subscriber is on a monthly or yearly plan because the billing mechanics in Stripe require fundamentally different approaches.
Stripe's pause_collection with behavior: "void" is used. While the subscription
is paused, Stripe automatically voids any invoices that fall within the pause window — the subscriber
is not charged and no draft invoice is left open. Billing resumes on exactly the same day-of-month
as before (the billing cycle anchor is unchanged).
Because monthly invoices are generated on a fixed billing date, the pause end date is rounded forward
to guarantee that the correct number of invoices are skipped. For every 30 days of
durationDays one payment cycle must be skipped: the Nth billing date is counted
strictly after the application date (where N = floor(durationDays / 30)), and if the pause end does
not already extend past that date, it is extended to 1 day past it.
Example: A user billed on the 10th of each month applies a 30-day promo on 5 April. The pause runs until 5 May (5 April + 30 days), which already extends past the 10 April billing date. Only the 10 April invoice is skipped. Billing resumes on 10 May at the same monthly anchor.
Edge case: If the same user applies the promo on 10 April (their billing date), the raw pause end falls on 10 May — exactly on the next billing date, not past it. The pause is therefore extended by one day to 11 May, ensuring the 10 May invoice is voided. Billing resumes on 10 June.
Trial subscribers: If a monthly subscriber is currently in a trial period, Stripe
does not allow delaying the start of pause_collection — it activates immediately.
However, resumes_at is set to trial_end + durationDays rather than
now + durationDays, so the subscriber receives their full promotional window
after the trial ends. The payment-skipping cycle count is also anchored to
trial_end instead of the application date.
pause_collection cannot be used for yearly subscribers: setting it to "void"
would void the entire annual invoice (giving a full year free for a short promo).
keep_as_draft defers the invoice but does not shift the billing anchor, so the next
charge would arrive only ~335 days after the delayed payment instead of a full year.
Instead, Stripe's trial_end is set with proration_behavior: "none".
This puts the subscription into trialing status and defers the next billing
date while leaving the already-paid invoice untouched. Crucially, Stripe resets the billing cycle
anchor to the trial_end date, so the next full annual charge fires exactly one year
after that date.
The trial end is set to discountBase + durationDays, where discountBase
is calculated as follows to ensure any plan trial period is respected:
trialing status (trial applied at creation):
discountBase = stripe_trial_end + 1 year — the promo days are added after the
end of the first paid year, not during the trial itself.active, plan has trial_period_days in
Stripe metadata, but no Stripe trial_end was set (most common case —
trial days stored in price metadata but not passed explicitly at creation):
discountBase = currentPeriodEnd + planTrialPeriodDays — the plan trial window
is honoured by shifting the base forward before adding the promo days.active, Stripe trial_end is set in the
past: discountBase = currentPeriodEnd — the trial has already been
accounted for in the billing anchor, no further adjustment is needed.Example — plan trial + Shopify promo: A yearly plan has
trial_period_days = 30 in its Stripe price metadata. A user subscribes on 17 April
2026; Stripe creates the subscription as active with
currentPeriodEnd = 17 April 2027. They immediately apply a 90-day Shopify promo.
discountBase = 17 April 2027 + 30 days = 17 May 2027.
trial_end = 17 May 2027 + 90 days = 15 August 2027. The next billing date becomes
15 August 2028.
Stripe rejects trial_end updates on incomplete subscriptions (those
awaiting initial payment confirmation). When a yearly subscription is created with a
shopifyPromoCode and payment is not yet confirmed, the code is stored as
pending_shopify_promo_code in the database instead of being applied immediately.
The promo is applied automatically when GET /api/subscriptions/verify-payment is called after the user completes payment on the frontend. The pending code is then cleared from the database.
Monthly subscriptions are not subject to this restriction — Stripe allows
pause_collection on incomplete subscriptions because it does not
generate a new invoice, so they are always applied immediately at creation.
Subscriptions in trialing status are treated as fully active throughout the codebase
(all access-control queries check for status IN ('active', 'trialing')), so the user
retains uninterrupted access.
The local pauseCollectionResumesAt column and currentPeriodEnd are both
written immediately on apply (before Stripe's webhook fires) to keep the displayed next billing
date accurate.
Multiple Shopify promotions can be stacked on the same subscription. If a deferral is already active when a new promo is applied, the new promo's days are added on top of the existing end date rather than starting from now. The stacking base is:
pause_collection.resumes_at timestamp.trial_end timestamp.Example: A yearly user already has a 30-day promo stacked (next billing 15 May 2027).
A second 30-day promo is applied. trial_end moves to 14 June 2027; billing then
resumes 14 June 2028.
When an admin cancels a Shopify promotion redemption via POST /api/promotions/redemptions/:id/cancel, the remaining active redemptions on that subscription are recomputed and the Stripe deferral is adjusted accordingly:
pause_collection is cleared entirely. Billing resumes at the next natural
billing date (the anchor is unchanged).
pause_collection.resumes_at is recomputed from the remaining redemptions
(summing their durationDays in applied_at order) and updated
in Stripe.
durationDays are subtracted directly from the current
trial_end (newTrialEnd = currentTrialEnd − cancelledDays).
This works for both the last and a stacked redemption because
pauseCollectionResumesAt already encodes originalPeriodEnd + sum(allActiveDays).
If the resulting newTrialEnd is still in the future, Stripe is updated with
the new timestamp. If it is in the past (i.e. the promotion period has already elapsed),
trial_end: "now" is sent to Stripe, immediately ending the trial and resuming
normal billing from the original period end.
In all cases, pauseCollectionResumesAt and (for yearly) currentPeriodEnd
are updated in the local database immediately so the displayed next billing date remains accurate.
/api/shopify/webhook
Handle Shopify order webhook (orders/paid event). Verified via HMAC-SHA256 signature — no session authentication required.
Headers:
Content-Type: application/json X-Shopify-Hmac-Sha256: base64_encoded_hmac_signature X-Shopify-Topic: orders/paid X-Shopify-Shop-Domain: your-store.myshopify.com
Request Body: (Shopify order payload — sent automatically by Shopify)
{
"id": 820982911946154508,
"order_number": 1234,
"email": "customer@example.com",
"financial_status": "paid",
"customer": {
"id": 115310627314723954,
"email": "customer@example.com",
"first_name": "John",
"last_name": "Smith"
},
"line_items": [
{
"id": 866550311766439020,
"product_id": 12345,
"title": "EveryWord Bundle - Old Testament",
"quantity": 1,
"sku": "EW-BUNDLE-OT",
"variant_id": 123456789,
"variant_title": "Default"
}
],
"created_at": "2026-04-07T12:00:00-04:00",
"currency": "USD",
"total_price": "49.99"
}
Response (200 OK):
{
"received": true
}
Error Responses:
// 401 - Missing or invalid HMAC signature
{
"message": "Missing signature header"
}
{
"message": "Invalid signature"
}
Notes:
shopify_order_id column (unique constraint) and silently skippedshopify_products database table — manage via the admin product endpoints beloworders/paid)https://your-api-domain.com/api/shopify/webhookSHOPIFY_WEBHOOK_SECRETPOST /api/shopify/products/import (admin) — this fetches all published products from the public Shopify storefront and upserts them into the shopify_products tableGET /api/shopify/products and identify each product's numeric IDPUT /api/shopify/products/:id to assign the correct tier (SINGLE_VOLUME, BUNDLE, OT_NT_SET, or FULL_SET)DELETE /api/shopify/products/:id (soft delete — sets is_active = false)The 100% forever discount coupon (Shopify Purchase Free Access) is automatically created in Stripe and the local database on the first Shopify webhook. No manual setup required.
/api/shopify/webhook-logs
List all Shopify webhook log entries with pagination, sorting, and filtering. Requires admin session.
Query Parameters:
page=1&perPage=20&sort[]=receivedAt&sort[]=DESC&filter[success]=false
Filterable fields:
q — search across email, promotion_code, and exact shopify_order_id, order_number, and product_idssuccess — true or falseskippedReason — NO_MATCHING_PRODUCTS, NO_EMAIL, or ALREADY_PROCESSEDtier — SINGLE_VOLUME, BUNDLE, OT_NT_SET, FULL_SETstartDate / endDate — filter by received_at (ISO 8601)Sortable fields: id, shopifyOrderId, orderNumber, email, tier, promotionCode, success, skippedReason, receivedAt, processedAt
Response (200 OK):
{
"data": [
{
"id": 1,
"shopifyOrderId": 820982911946154508,
"orderNumber": 1234,
"email": "customer@example.com",
"productIds": ["7482588725342"],
"tier": "SINGLE_VOLUME",
"promotionId": 42,
"promotionCode": "ABCD1234EFGH5678",
"success": true,
"skippedReason": null,
"errorMessage": null,
"receivedAt": "2026-04-16T10:00:00.000Z",
"processedAt": "2026-04-16T10:00:01.234Z"
},
{
"id": 2,
"shopifyOrderId": 820982911946154509,
"orderNumber": 1235,
"email": "customer2@example.com",
"productIds": ["9999999999999"],
"tier": null,
"promotionId": null,
"promotionCode": null,
"success": false,
"skippedReason": "NO_MATCHING_PRODUCTS",
"errorMessage": null,
"receivedAt": "2026-04-16T11:00:00.000Z",
"processedAt": "2026-04-16T11:00:00.123Z"
}
],
"total": 2
}
Headers:
Content-Range: shopify-webhook-logs 0-1/2 Accept-Range: shopify-webhook-logs Access-Control-Expose-Headers: Content-Range X-Total-Count: 2
Notes:
receivedAt DESC (most recent first)skippedReason is set when the webhook was received but no promotion was created: NO_MATCHING_PRODUCTS means no product in the order matched a known tier, NO_EMAIL means the order had no email address, and ALREADY_PROCESSED means the order ID was already in the databaseerrorMessage is set only on unexpected processing errorsproductIds contains all Shopify product IDs from the order's line items/api/shopify/products
List all Shopify products stored in the database with pagination, sorting, and filtering. Requires admin session.
Query Parameters:
page=1&perPage=20&sort[]=tier&sort[]=ASC&filter[isActive]=true
Response (200 OK):
[
{
"id": "7482588725342",
"title": "Romans (ESV)",
"tier": "SINGLE_VOLUME",
"isActive": true,
"createdAt": "2026-04-14T10:00:00.000Z",
"updatedAt": null
},
...
]
Includes Content-Range and X-Total-Count headers.
/api/shopify/products/import
Fetch all published products from the Shopify public storefront and upsert them into the shopify_products table. Existing rows keep their tier and is_active values; only title is updated if changed. Requires admin session.
Request Body: (none)
Response (200 OK):
{
"fetched": 87,
"upserted": 87
}
/api/shopify/products/:id
Get a single Shopify product by its numeric ID. Requires admin session.
Response (200 OK):
{
"id": "7482588725342",
"title": "Romans (ESV)",
"tier": "SINGLE_VOLUME",
"isActive": true,
"createdAt": "2026-04-14T10:00:00.000Z",
"updatedAt": null
}
Error Responses:
// 404 - Product not found
{
"message": "Product not found"
}
/api/shopify/products/:id
Update a Shopify product's title, tier, or active status. All fields are optional. Requires admin session.
Request Body:
{
"tier": "BUNDLE",
"isActive": true
}
Valid tier values: SINGLE_VOLUME, BUNDLE, OT_NT_SET, FULL_SET
Response (200 OK):
{
"id": "7482588725342",
"title": "Romans (ESV)",
"tier": "BUNDLE",
"isActive": true,
"createdAt": "2026-04-14T10:00:00.000Z",
"updatedAt": "2026-04-14T11:00:00.000Z"
}
Error Responses:
// 400 - Invalid tier
{
"message": "Invalid tier"
}
// 404 - Product not found
{
"message": "Product not found"
}
/api/shopify/products/:id
Soft-delete a Shopify product by setting is_active = false. The product is excluded from webhook tier lookups but remains in the database. Requires admin session.
Response (204 No Content): (empty body)
Error Responses:
// 404 - Product not found
{
"message": "Product not found"
}