Shopify Integration Endpoints Index

Overview

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.

Promotion Tiers

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)

How It Works

  1. Customer purchases a book product on Shopify
  2. Shopify sends an orders/paid webhook to /api/shopify/webhook
  3. The API determines the highest promotion tier from the order's line items
  4. A SUBS promotion code is created (linked to a 100% discount coupon with a time-limited end date)
  5. An email is sent to the customer with the promotion code:
    • Subscribed users: Code + instructions to apply (pauses billing for the free period)
    • Registered, not subscribed: Code + instructions to subscribe and apply
    • Not registered: Registration invite with embedded promo code link
    • Already on free access: Code + suggestion to share with someone else

Discount Extension

If 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.

Pre-requisites

Environment Variables

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 Mechanics

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.

Monthly subscribers

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.

Yearly subscribers

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:

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.

Deferred application for new yearly subscriptions

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.

Stacking

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:

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.

Cancellation effects

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:

In all cases, pauseCollectionResumesAt and (for yearly) currentPeriodEnd are updated in the local database immediately so the displayed next billing date remains accurate.

Shopify Endpoints

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

  • This endpoint is called automatically by Shopify — it is not meant to be called manually
  • Authentication is via HMAC-SHA256 signature verification, not session tokens
  • Always returns 200 to prevent Shopify from retrying (even on internal errors)
  • Duplicate orders are detected via the shopify_order_id column (unique constraint) and silently skipped
  • When an order contains multiple matching products, only the highest-tier promotion is created
  • The promotion code is emailed to the order's email address
  • Products are mapped to tiers via the shopify_products database table — manage via the admin product endpoints below
  • Orders without any mapped products are silently ignored

Shopify Admin Setup

1. Create the Webhook

  1. Go to Shopify Admin → Settings → Notifications → Webhooks
  2. Click "Create webhook"
  3. Select event: Order payment (orders/paid)
  4. Set format: JSON
  5. Set URL: https://your-api-domain.com/api/shopify/webhook
  6. Copy the signing secret and set it as SHOPIFY_WEBHOOK_SECRET

2. Import and Map Product IDs

  1. Call POST /api/shopify/products/import (admin) — this fetches all published products from the public Shopify storefront and upserts them into the shopify_products table
  2. List the imported products with GET /api/shopify/products and identify each product's numeric ID
  3. For each product, call PUT /api/shopify/products/:id to assign the correct tier (SINGLE_VOLUME, BUNDLE, OT_NT_SET, or FULL_SET)
  4. To deactivate a product so it is ignored by the webhook handler, call DELETE /api/shopify/products/:id (soft delete — sets is_active = false)

3. Coupon (Auto-Created)

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.

Shopify Webhook Log Endpoints

GET /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_ids
  • successtrue or false
  • skippedReasonNO_MATCHING_PRODUCTS, NO_EMAIL, or ALREADY_PROCESSED
  • tierSINGLE_VOLUME, BUNDLE, OT_NT_SET, FULL_SET
  • startDate / 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:

  • Default sort is 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 database
  • errorMessage is set only on unexpected processing errors
  • productIds contains all Shopify product IDs from the order's line items

Shopify Product Admin Endpoints

GET /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.

POST /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
}
GET /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"
}
PUT /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"
}
DELETE /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"
}