Order to Rewards
From Shopify order webhook to stamps and a recorded stamp outcome.
The single most important flow in the platform. An order arrives as a Shopify webhook and leaves with stamps on every campaign it qualifies for, plus a stampOutcome that says why it did or did not earn them. Every step is durable and safe to repeat. The program is stamps only. Points and tiers were removed on 2026-10-16 (see Loyalty).
Ingestion
verifyShopifyHmacrecomputes HMAC-SHA256 of the raw body (timingSafeEqual). Bad HMAC returns 401 — Shopify does not retry 401, and an internal failure must return 500 or the event is lost (2xx means "delivered, don't retry").storeWebhookEventdedupes onX-Shopify-Webhook-Id. The payload goes to a separatewebhookPayloadstable — events were blowing Convex's 16 MB query limit when dashboards loaded thousands of rows with inline payloads.- The event is enqueued on
shopifySyncPool(maxParallelism 3).
Buyer matching
processOrderCreate links the order to an app profile, trying each source in turn.
- The checkout session key, which resolves to the session's user.
prepareCheckoutcreates the session and also sets a_betterAuthUserIdcart attribute, but any storefront cart can carry that attribute, so the webhook only logs a mismatch with the session and never links by the attribute alone. findProfileForBuyerinwebhooks/orderBuyer.ts, which tries the Shopify customer id, then the customer and checkout emails (case-insensitive), then the customer phone normalized to E.164. A Malaysian mobile number typed without its trunk 0 still matches.
An email or phone links only an account that verified that exact value in Better Auth (emailVerified, phoneNumberVerified). The matcher looks the value up in Better Auth's user table and in the profile indexes, and checks each candidate's verification. The email and phone on a profile are free text, so a match on them alone would hand a shopper's orders, address included, to anyone who typed that shopper's email into their own profile. The shipping-address phone never links an order, because it belongs to whoever receives the parcel. A buyer who has not verified yet keeps the order unlinked until the 30-day claim runs after they verify.
Only step 1 sets checkoutAttributedUserId. That field lets the app show the order to its user even after staff relink userId, so a contact match must not set it.
When the order links a profile that had no shopifyCustomerId, the webhook saves the id onto the profile and schedules a 30-day claim for that user. From a checkout session it saves the id only when the user verified the order's email or phone, because Shopify files an order under whichever customer owns the email typed at checkout. The saved id routes every later order under that customer to the profile, so every writer of it requires a verified detail: the order webhook, the customers/update webhook, and the Shopify customer sync (shopify/actions.ts:processShopifySync), which searches and creates customers with the verified email and phone only. prepareCheckout likewise sends the verified email as the cart's buyer identity, never the profile email. An order with no match is stored under the Shopify customer id, or "anonymous" when even that is missing.
Links written before these rules are not undone automatically. On dev on 2026-10-19, 379 of 538 links could not be proven either way, mostly phone sign-ups the sync linked through an email they typed, and the app has no way for them to verify an email. A read-only audit lists the links a person should settle: those whose customer id another profile also holds, and unprovable ones whose details another account verified.
bunx convex run migrations/shopifyCustomerLinks:auditShopifyCustomerLinks '{}'Every order stores buyerEmail, buyerPhone and shippingPhone in normalized form, so a later sign-up can find it. The insert also sets an early outcome. An unlinked order gets no_app_user, and a linked order that Shopify has not reported paid gets awaiting_payment.
Starting a run
Every path that can make an order eligible calls startOrderRewards in gamificationLoyalty/orderRewards.ts. The callers are the create webhook, the non-paid → paid transition in processOrderUpdate (the only path for COD), the 30-day claim, and three dashboard mutations: dashboardLinkOrderToUser on a paid order, dashboardUpdateOrderStatus when it marks a linked order paid, and dashboardReprocessOrderRewards.
startOrderRewards refuses an order whose run already finished or is queued, unless the caller passes force. It sets orders.rewardsQueuedAt in the caller's transaction, then starts the workflow with startAsync. A failing workflow step therefore never rolls back the order insert.
The durable workflow
orderRewardsWorkflow (gamificationLoyalty/workflows.ts, @convex-dev/workflow) persists each step, so a failure retries from the failed step and keeps stamps already awarded.
getOrderRewardsStatusskips the run when the order is settled andforceis off.isOrderRewardsSettledcalls an order settled whenrewardsProcessedAtis set, or when the legacypointsEarnedholds any non-null value, including 0. Orders processed before 2026-10-16 carry only the legacy marker.getActiveCampaignsInternalreads the running campaigns.- For each campaign,
evaluateStampEligibilitycomparesminPurchaseAmountCentswith the order subtotal in cents. A campaign the order falls short of records its shortfall and gets no stamp. collectStampawards stamps for each eligible campaign. It writes onestampHistoryrow per stamp, dedupes by(orderId, campaignId), maintains thecampaignStampsaggregate per row, and pays out special positions instantly as the card crosses them.finalizeOrderRewardscounts the order'sstampHistoryrows and writesrewardsProcessedAt,stampOutcome,stampsAwardedandstampShortfallCents. This is the done marker.
Counting rows in step 5, instead of trusting the run's own tally, keeps a forced rerun of an already-stamped order reading as awarded.
Rewards basis is the subtotal, after discounts and before shipping and tax. Naming trap: the workflow arg is named orderTotal but carries that subtotal. Do not "fix" the name. Renaming breaks in-flight durable runs.
Stamp outcomes
orders.stampOutcome (gamificationLoyalty/stampOutcome.ts) holds one of seven values. The mobile order detail and the dashboard orders list both read it.
| Value | Set by | Meaning |
|---|---|---|
awarded | workflow | At least one stamp landed for this order. |
below_min_spend | workflow | Every active campaign's minimum was above the subtotal. stampShortfallCents holds the smallest gap. |
no_active_campaign | workflow | The order was paid and linked, but no campaign was running. |
not_awarded | workflow | A campaign was eligible but refused the award, for example because it ended mid-run. |
no_app_user | webhook, reprocess | No app profile matched the buyer. A sign-up or link inside the claim window re-checks it. |
awaiting_payment | webhook, link | Linked, but Shopify has not reported it paid. |
needs_review | 30-day claim | Either staff awarded this buyer stamps by hand after the order was placed, or the order matched only by its shipping phone. A person decides. |
Orders processed before 2026-10-16 have no outcome until the backfill below fills one in.
30-day claim
claimRecentOrdersForUser runs when a profile is created, gains an email or phone, or gains a Shopify customer id (from the order webhook or the customer webhook). It finds paid orders from the last 30 days that never ran rewards, using only contact details the user has verified in Better Auth: the email when emailVerified is true, and the phone when phoneNumberVerified is true. A profile's own email and phone are free text, so they never claim anything. It reads the by_buyer_email_processed_at and by_buyer_phone_processed_at indexes, plus the user's own unprocessed orders. It links each order that is unlinked or already this user's, then calls startOrderRewards. An order that matches only through by_shipping_phone_processed_at (often a gift recipient) gets needs_review and stays unlinked. An order placed before a manual admin_test stamp award for the same user also gets needs_review, since the manual award was probably compensation for it.
One exception guards against double compensation. When the user has a manual stamp award (stampHistory.activityType === "admin_test") dated on or after an order, the claim marks that order needs_review and does not stamp it.
The claim is idempotent. An order it starts carries rewardsQueuedAt from the same transaction, so a second claim skips it.
Reprocessing from the dashboard
dashboardReprocessOrderRewards (admin and up) backs the Reprocess stamps button on the order page. It refuses an unpaid order or one whose rewards were reversed. When the order has no linked profile, it runs findProfileForBuyer again and links the match, or sets no_app_user and stops. Then it calls startOrderRewards with force and writes an order.reprocess_rewards audit entry.
A forced rerun is safe on an order that already earned stamps, because collectStamp dedupes per (orderId, campaignId). A new stamp that lands on a special position does mint a real voucher code on the live Shopify store.
Backfill per deployment
After this backend ships to a deployment, run the backfill once there:
bunx convex run migrations/orderStamps:fillOrderStampFields '{}'It lowercases profile emails, fills buyer keys and outcomes on older orders, and clears checkoutAttributedUserId on orders no app checkout session placed. It never stamps anything.
Cancellation reversal
processOrderCancelled is guarded by orders.rewardsReversedAt, which it sets once, so duplicate cancelled webhooks cannot double-reverse.
- Shake vouchers still active from this order →
cancelled+ counter decrement + Shopify code removal. currentStampsdecremented best-effort (floored at 0).stampHistoryrows are preserved — cycle-accurate reversal is intentionally not attempted because later orders may have advanced the card.- Vouchers used by the order are restored via
restoreVouchersForCancelledOrder(back to active with the Shopify code re-attached, or expired if validity passed).
A reversed order never earns stamps again. The claim and the reprocess button both skip it.