Skip to content

07 — Email/SMS Request Delivery Pipeline (Blueprint §4)

Cursor-ready plan: order webhooks → campaign eligibility → per-product TestimonialRequest rows → scheduled send → reminders → request events.

07 — Email/SMS Request Delivery Pipeline

Section titled “07 — Email/SMS Request Delivery Pipeline”

Source: 02-Implementation-Blueprint.md§4) Email/SMS Workflow (Operational Detail) (steps 1–8).

Product alignment: 01-Post-Purchase-Video-Testimonial-Collector-Plan.md§6 MVP (post-purchase campaigns, tokenized links, one request per delivered product line item, email templates, reminders).

This document is a build spec only. No code changes are implied until a task references this file.

Related plans (do not duplicate):

  • 06-public-submission-page-screen-13.md — customer consumes submissionToken on /t/:token.
  • 05-storefront-widgets-and-read-api.mdpublishing approved media to the storefront.

When Shopify fires the right order webhook, the app evaluates active campaigns, creates one TestimonialRequest per eligible delivered line item (product), schedules the first outreach, a worker/cron sends email and/or SMS using templates, appends rows to TestimonialRequestEvent, and sends reminders until the customer submits or max reminders is reached.


1) Preconditions (data + config that must exist)

Section titled “1) Preconditions (data + config that must exist)”
PrerequisiteBlueprint ref
TestimonialCampaign, TestimonialCampaignProduct (if not “all products”)§5.2, §5.3
TestimonialRequest, TestimonialRequestEvent§5.5, §5.6
TestimonialTemplate (or equivalent) for initial_request / reminder_1 / reminder_2§5.4
Shop row with testimonial app installed, accessToken validStandard
Product mirror optional but helpful for line-item titles in messagesProduct / Admin API
Public base URL for submissionUrl = ${APP_URL}/t/${token} (or app proxy path — must match 06 route)§6 public routes

You may stub template bodies in DB seed until Screen 4 (Templates) admin exists; this pipeline still needs readable template rows to render emails.


  • Webhook entrypoint(s) for order events: at minimum support what TestimonialCampaign.triggerEvent allows (order_paid / order_fulfilled per §5.2). Register in shopify.app.toml and implement app/routes/webhooks.orders.*.jsx (exact file names from §6).
  • Idempotent processing: the same webhook delivery must not create duplicate TestimonialRequest rows for the same (shop, order, line item product, campaign) — use a unique business key or idempotency table (see §5).
  • Eligibility engine: campaign status, allProducts vs TestimonialCampaignProduct allowlist, delayDays, channel.
  • Line-item expansion: one job row per delivered product line item the blueprint requires (see §3.2 for “delivered” definition).
  • Token + URL: generate high-entropy submissionToken; set submissionUrl (and persist TestimonialRequest.submissionUrl per §5.5).
  • Schedule: set scheduledFor = order_event_time + delayDays (use UTC; document merchant interpretation if you later add “business days”).
  • Sender worker: process due TestimonialRequest rows where status in scheduled / queued / failed (retry policy) and scheduledFor <= now.
  • Send email (reuse app/lib/email.server.js + Brevo env pattern from boilerplate) and/or SMS via a pluggable provider (see §6).
  • Events: insert TestimonialRequestEvent for queuedsent → (optional) delivered / opened / clicked / failed per Screen 10 status list and §5.6.
  • Reminders: if reminderEnabled and no submission by reminderDelayDays after first send, up to maxReminders, using reminder_1 / reminder_2 templates.
  • Stop reminders when TestimonialRequest.submittedAt is set (from 06 submit path) or status becomes submitted.
  • Full admin UI for campaigns (Screens 2–3) and templates (Screen 4) — minimum seed or migration defaults suffice for pipeline testing.
  • Screen 10 (Requests Log) UI — surface same DB rows; optional read-only page later.
  • Deep delivered/open tracking requires provider webhooks or pixel links — v1 can record sent reliably and clicked when customer hits /t/:token (loader updates clickedAt per 06).

Align §5.2 triggerEvent with Shopify webhook topics:

  • order_paid → typically orders/paid payload (confirm naming with Shopify Admin API version in use).
  • order_fulfilledorders/fulfilled or fulfillment-centric payloads — pick one canonical mapping and document it in code comments.

Implementation: one route file per topic or shared handler with topic discriminator — either is fine if tests cover both.

From each webhook payload, derive:

  • shopDomain (session lookup → Shop)
  • shopifyOrderId (GID or numeric — normalize to stored format consistent with TestimonialRequest.shopifyOrderId)
  • Line items: only items representing physical/digital products you support; skip shipping-only lines.
  • Delivered criterion: for MVP, either:
    • Fulfillment webhook: consider line items fulfilled, or
    • Paid webhook: consider paid items only
      Choose explicitly in code + README fragment so QA knows expected behavior.

Use Shopify webhook HMAC verification per Shopify docs / existing boilerplate pattern (authenticate.webhook or equivalent). Reject invalid signatures before DB writes.

Shopify retries webhooks. Before creating requests:

  • Compute webhookDeliveryId if available in headers, or
  • Hash (shopId, shopifyOrderId, triggerEvent, lineItemsStableFingerprint) and store processed webhook ids in a small WebhookReceipt table or rely on unique constraint on (shopId, shopifyOrderId, shopifyProductId, campaignId) on TestimonialRequest.

If duplicate, exit 200 without error after verifying rows exist.


4) Campaign eligibility (pure logic module)

Section titled “4) Campaign eligibility (pure logic module)”

Extract evaluateCampaignsForOrder({ shopId, orderPayload }) into app/lib/testimonial-campaign-eligibility.server.js (name flexible).

Rules:

  1. Load campaigns where shopId matches and status === 'active'.
  2. For each campaign, check triggerEvent matches this webhook’s semantic type.
  3. Audience:
    • If allProducts === true → all eligible line items pass.
    • Else → line item’s shopifyProductId must exist in TestimonialCampaignProduct.
  4. Produce candidates: (campaignId, shopifyProductId, shopifyVariantId?, quantity handling?).

Quantity: blueprint says one testimonial request per delivered product line item. If quantity > 1, decide:

  • Option A: one TestimonialRequest per unit (multiple rows), or
  • Option B: one row per unique product per order (single row).

Option A matches stricter “one link per item shipped.” Document the choice in the module header.


For each candidate from §4:

  1. Generate submissionToken (opaque, unguessable).

  2. Build submissionUrl → must open 06 page.

  3. Insert row with:

    • status: start as scheduled (or queued if you send immediately — align enum with Screen 10 list).
    • scheduledFor: now + campaign.delayDays or webhook timestamp + delay — match blueprint §5.5.
    • Copy customer email/phone from order into customerEmail / customerPhone for channel routing.
    • campaignId, shopifyOrderId, shopifyProductId, etc.
  4. Insert TestimonialRequestEvent row: eventType = queued (or scheduled).

Unique constraint: enforce at DB level to prevent duplicate outreach for same logical unit.


Provide GET or POST internal route, e.g. app/routes/api.cron.testimonial-send-due.jsx, protected by:

  • CRON_SECRET header or query param (same pattern as other cron routes in repo), or
  • Platform scheduler with private network access.

Run every 1–5 minutes in production.

Select TestimonialRequest where:

  • shopId active,
  • scheduledFor <= now(),
  • status in allowed set (scheduled, failed with retry budget),
  • Not yet sent for initial send (track via sentAt or status).
  • Resolve template: TestimonialTemplate for (shopId, campaignId or null, channel=email, templateType=initial_request) with fallback to shop default.

  • Substitute variables per blueprint Screen 4 list: customer_first_name, shop_name, product_name, submission_link, incentive_text.

  • Product name: join Product by shopifyProductId or fetch from cached title on request row if you denormalize.

  • Send via existing mailer; on success:

    • Set sentAt, status = sent (or keep sent separate from reminder states — define enum clearly).
    • Insert event sent.

On failure: lastError, event failed, increment retry or dead-letter per policy.

  • Pluggable adapter: Twilio/MessageBird/etc. Store minimal config on Shop or campaign (future); v1 can no-op with feature flag if SMS provider env missing, but record failed with clear reason.

Schedule next send only if:

  • reminderEnabled,
  • submittedAt is null,
  • reminderCount < maxReminders,
  • Elapsed time since first sentAtreminderDelayDays (use calendar days UTC unless product says otherwise).

Template selection:

  • reminder_1 for first reminder,
  • reminder_2 for second (if maxReminders allows).

Increment reminderCount after each reminder send. Insert matching TestimonialRequestEvent rows.

Stop when 06 marks submission complete (submittedAt / status submitted).


8) Click / open tracking (v1 realistic subset)

Section titled “8) Click / open tracking (v1 realistic subset)”
  • Clicked: when /t/:token loader runs (06), set clickedAt once → correlates with funnel (Screen 11).
  • Opened: depends on email provider open pixel — optional v2.
  • Delivered: provider-dependent — optional v2.

Do not block MVP on open/delivered webhooks.


9) Environment variables (add to .env.sample in implementation task)

Section titled “9) Environment variables (add to .env.sample in implementation task)”
VariablePurpose
CRON_SECRET or reuse existingProtect cron send route
APP_PUBLIC_URL / existing SHOPIFY_APP_URLBuild submissionUrl correctly
Brevo keysAlready in boilerplate for email
SMS provider keysOptional, namespaced

10) Testing checklist (acceptance criteria)

Section titled “10) Testing checklist (acceptance criteria)”
  • Webhook with invalid HMAC → 401/403, no DB rows.
  • Valid order → creates N TestimonialRequest rows matching N eligible line items; no duplicates on replay.
  • Campaign paused → no new rows.
  • delayDays respected: scheduledFor is correct in UTC.
  • Cron picks due rows, sends email, sets sentAt + event row.
  • After submit (06), reminder cron does not send further messages for that request.
  • Failure path: provider error → failed event + lastError populated.

  1. Prisma migrations for §5.5 / §5.6 (and campaign tables if missing).
  2. Webhook route(s) + HMAC + idempotency.
  3. Eligibility module + unit-style tests (pure functions).
  4. Insert TestimonialRequest + initial event.
  5. Email template resolution + variable substitution.
  6. Cron send route + initial outreach.
  7. Reminder scheduler loop.
  8. Wire clickedAt from 06 if not already (cross-plan coordination).

  • 02-Implementation-Blueprint.md§4, §5.2–§5.6, §6 webhook list, §7 steps 1–4 & 6–8.
  • 01-Post-Purchase-Video-Testimonial-Collector-Plan.md§6 Collection & Requests + Messaging.

Existing plans in this folder: 05-storefront-widgets-and-read-api.md, 06-public-submission-page-screen-13.md. This file is 07-… to keep ordering consistent.