
Project Story
Subscription kit fulfillment that ships one kit per order, even when webhooks retry
Every BodyAudit renewal orders a physical blood test kit through Tasso, whose API can't tell a repeat request from a new one. We built the fulfillment path in Next.js and Prisma so a retried Shopify webhook is recognized and skipped by a claim row with a unique key, the panel rotation moves forward exactly once inside one Postgres transaction, and the shipping address comes from Recharge instead of Shopify's customer records.
- Client
- BodyAudit
- Industry
- Preventive health
- When
- 2026
- Status
- Live since August 2026
- Next.js
- TypeScript
- Prisma
- PostgreSQL
- Shopify
- Recharge
Why a retry can cost a real box
When a BodyAudit subscription renews, Recharge (the subscription billing service) charges the card and Shopify creates an order. Shopify then sends our server an orders/paid webhook, a short message saying the order was paid. Our server asks Tasso, the kit vendor, to ship a test kit to that person.
Shopify delivers webhooks at least once. If our server is slow to answer or returns an error, Shopify sends the same message again. Here, the second message would place a second order with Tasso, and Tasso’s API has no idempotency key (a request ID that lets it say “I’ve already done this one”). So a second order means a second physical kit in the mail.
The order path, end to end
The webhook lands on a Next.js route handler. In order, it:
- Verifies the
x-shopify-hmac-sha256header against the raw body with HMAC-SHA256 and a constant-time comparison, and returns 401 on a mismatch. - Ignores every topic except
orders/paid. - Finds the local customer. Shopify’s webhook sends a numeric customer ID and our database stores the
gid://shopify/Customer/…form, so the handler converts before the lookup. An unknown customer gets a 200 so Shopify stops retrying an order we’ll never handle. - Upserts a local copy of the Shopify order in a transaction, keyed on the Shopify order ID.
- Looks up the Recharge charge that produced the order, with
GET /charges?external_order_id=<id>, for the shipping address and line items. - Maps the first line item SKU that has one to a Tasso configuration ID through a JSON map held in an environment variable. Tasso takes one configuration per order, and an order with no mappable SKU returns 200 with nothing to ship.
- Gets or creates the Tasso patient. The shipping address lives on the patient in Tasso’s model, not on the order.
- Claims the order, calls Tasso’s
POST /orders, then finalizes the claim.
Status codes decide whether Shopify tries again. Anything we’d want another attempt at, like a missing Recharge charge or a Tasso error, returns 500 so Shopify retries. Anything that will never succeed returns 200 or 400 so it doesn’t.
Claim the order before the kit ships
Step 8 is the part that keeps the second kit from shipping. It writes down that we’re handling an order before doing the part that can’t be undone.
The Order table has a unique constraint on shopifyOrderId and another on tassoOrderId, which is required. Before calling Tasso, the server inserts a placeholder row with the real Shopify order ID and a tassoOrderId of pending: followed by the Shopify order ID, which satisfies the second constraint until the real ID exists. If the insert fails with Prisma’s P2002 unique violation, another delivery already owns this order, so the server answers Shopify with a 200 and stops. If the insert goes through, this delivery owns the order. It calls Tasso, and once Tasso answers, it replaces the placeholder with Tasso’s order ID and mapped status on that same row.
If the Tasso call fails, the server deletes the placeholder so Shopify’s next retry can try again instead of being skipped forever. The delete matches on both the Shopify order ID and the pending: value, so it can only remove a row that was never finalized.
The claim leans on the database’s unique constraint instead of a lock in application memory, so two deliveries arriving at the same moment on different server instances can’t both win.
Moving the rotation forward once
BodyAudit’s testing runs as three panels (Performance, Longevity and Energy) in an order the subscriber picks, one panel per renewal. A PanelRotation row per user holds rotationOrder as an array of panel IDs, a currentPosition and a cycleNumber. Each renewal has to move the subscriber to their next panel, and that listens to a different webhook, Recharge’s charge/paid, which can repeat too. A double advance here would skip a panel entirely.
One small detail cost us a fix early on. The handler was first written for charge/completed, which Recharge’s API rejects as a webhook topic. The webhook is registered as charge/paid now, and the handler accepts both.
The handler verifies Recharge’s HMAC, then does a cheap read on ProcessedRechargeEvent (unique on chargeId) and returns early if the charge is already there. The real guard is inside one Prisma interactive transaction:
- insert the
ProcessedRechargeEventrow for this charge, and abort with a “seen it” result onP2002 - read the rotation and work out the next position, wrapping to 0 and adding 1 to
cycleNumberat the end of the order - update with
updateManywherecurrentPositionandcycleNumberstill match what was read, an optimistic lock - if that update touches zero rows, throw, which rolls back the whole transaction
Recording the charge and moving the rotation commit together or not at all. A repeated charge hits the existing ID and does nothing. A crash halfway can’t leave a charge marked done with the subscriber still on the old panel. When the optimistic lock loses, the rollback removes the charge record too, the route returns 500, and Recharge’s retry runs the whole thing again against fresh state. A charge for an unknown email, or a user with no rotation, is recorded as processed and returns 200.
Telling Recharge what ships next
Recharge decides what the next renewal actually ships, so after the transaction commits the server swaps the subscription’s variant to the next panel’s Shopify variant (the sex-specific one, with the numeric ID pulled out of the Shopify GID because Recharge wants integers). It tries up to three times. If the third attempt fails, it writes an error-level log with event: rotation_variant_desync and the user and panel IDs, because the alternative is quietly shipping the wrong panel next time.
That swap runs after the transaction has committed, so no database transaction is held open while another company’s API answers. The cost is a window where our database says panel 2 and Recharge still says panel 1, and the desync event is how someone finds out the window didn’t close. Wiring that event into external alerting and a reconciliation job was listed as follow-up work around the production switch, not part of this change.
Tasso doesn’t send webhooks for order status, so status comes from polling. The status mapper originally threw on statuses Tasso returns mid-fulfillment (readyToShip, inLogistics, awaitingShipment), which was a confirmed crash. It now maps those to PENDING_FULFILLMENT and turns anything unrecognized into PROBLEM with a warning.
The address comes from Recharge
This one we found on staging, in May 2026. Renewal charges were going through and Shopify orders were being created, but no kits had been ordered since April. The server had been reading the shipping address and SKU from Shopify’s Admin API Customer object, and Shopify only exposes customer PII through that API on certain store plans. The staging store’s plan wasn’t one of them, so every lookup came back ACCESS_DENIED, and the handler gave up before it reached Tasso. The staging logs showed it on three orders in a row.
We moved the lookup to the Recharge charge, which already carries the shipping address and the product SKU. Recharge stores the state as a full name, “Utah”, and Tasso’s district1 field wants “UT”, so there’s a small converter for US states and territories that also passes a two-letter code through unchanged. If the webhook payload has no customer name, the Recharge shipping name fills in. The side effect we liked is that the kit order no longer needs Shopify’s customer data at all.
What it can't settle on its own
One case is still open. If Tasso accepts an order but our call errors out before we hear back, say on a timeout, the server releases the claim and Shopify’s retry will order again. Releasing on any error is right for a request Tasso refused and wrong for that one, and without an idempotency key on Tasso’s side we can’t ask which it was. Rare, but possible.
There’s a second, smaller gap the other way around. If the kit ships and the follow-up database write fails, the row is left as a placeholder and a CRITICAL log line records both order IDs for someone to reconcile. It isn’t retried, since a retry would be blocked by the claim anyway.
We also don’t have an automated test suite around these routes. The production-readiness fixes were checked against the live sandbox APIs for Tasso, Recharge and Shopify, with a test plan of redelivering one orders/paid and expecting exactly one kit, and sending a duplicate charge/paid and expecting one advance.
Related case study
BodyAuditQuestions about this project
HQ built kit fulfillment for BodyAudit, a preventive health subscription that ships at-home blood test kits through the vendor Tasso. HQ wired Shopify, Recharge and Tasso together in Next.js, Prisma and PostgreSQL so that a retried Shopify webhook is recognized and skipped before a second physical kit can be ordered, and so a repeated Recharge charge advances the subscriber's panel rotation exactly once.
Before calling the kit vendor, BodyAudit's server inserts a placeholder order row keyed on the unique Shopify order ID, with a temporary vendor order ID of "pending:" plus the Shopify ID. A repeated webhook for the same order hits the unique constraint and stops without placing another kit order. If the vendor call fails, the placeholder is removed, but only while it is still a placeholder, so a later retry can try again.
BodyAudit tests in a rotation of three panels. When Recharge reports a paid charge, the server records that charge ID and advances the rotation inside a single database transaction, using an optimistic lock on the current position and cycle number. A duplicate charge event is ignored and a partial failure can't advance one without the other. A lost race rolls back and returns a 500 so Recharge retries. The server then updates the Recharge subscription to the next panel, retrying up to three times and logging a desync event if it can't.
BodyAudit's server reads the shipping address and product SKU from the Recharge charge, found by the Shopify order ID, instead of Shopify's customer records. HQ made that change after Shopify's customer data turned out to be unavailable through the Admin API on some store plans, which had stopped renewal orders from producing kit orders on the staging store.
Shopify and Recharge retry a webhook when the receiving server returns an error. BodyAudit's handlers return 500 for failures worth another attempt, such as a missing Recharge charge, a kit vendor error or a lost race on the rotation update, and return 200 for cases that will never succeed or are already handled, such as an unknown customer, an order with no kit SKU or a duplicate delivery. The claim row and the processed-charge table make those retries safe.
Let's build together
Whether you're looking to design, build, update, or refresh an existing app or website, we can help. Even if we're not a good fit, we will help point you to somebody who might be.
Contact us