Self-processed checkout integration

Keep checkout on your own payment rail and let Truemed handle the HSA/FSA side: eligibility, the shopper’s health survey, the Letter of Medical Necessity (LMN), and substantiation. You charge the card and report the charge to Truemed, and Truemed produces the compliance artifacts and the reconciliation data.

This is the model behind a Shop Pay self-processed checkout. The shopper pays through your existing flow, and a Truemed session runs alongside it to qualify the purchase and record it for HSA/FSA purposes. Truemed never moves money here. Every capture and refund you report describes a charge you settled yourself.

How the flow works

A cart that needs an LMN takes the longest path: survey first, then the hold, then review.

Shorter paths exist. A fully pre-approved cart, or a shopper whose existing LMN already covers the cart, is ready for the hold as soon as you create the session, and never leaves your checkout. See Pre-approved carts and Returning customers and LMN reuse.

One session tracks one checkout from cart to settlement:

  1. Create a session with the cart. Truemed returns the eligibility breakdown and, when the shopper needs the survey, a hosted-survey URL.
  2. Send the shopper to the survey to establish their LMN. Truemed returns them to your return_url.
  3. Read the session before you take the card. The status tells you what to do next; in the common case it’s pending_authorization, so you place the hold and signal it, which starts clinical review.
  4. Capture when review finishes. Read the per-item amounts to size the capture, capture the hold on your rail, and report it.
  5. Report refunds as the order evolves. Cancel instead if the session never captured.

Reading qualification at the return

The shopper arriving back on return_url is navigation, not a decision. Two things tell you where qualification actually stands, and they answer different questions.

GET /api/v2/shop_pay/truemed_sessions/{id} is the authoritative read. Call it when the shopper returns and gate on qualification_info.qualification_status.

Webhooks cover the transitions that happen while the shopper isn’t in front of you, review finishing above all. Delivery rides a background worker, so a webhook can arrive before or after the shopper returns. A missing webhook doesn’t mean “not ready” at the moment the shopper lands. A session emits truemed_session.pending_authorization at most once, when intake completes, and later exactly one terminal truemed_session.truemed_review_complete, whose qualification_status says whether review approved or rejected the request.

Pre-approved carts and reused LMNs never send the shopper anywhere, so the table below applies only to sessions that went through the survey. Any of these statuses can be live when the shopper lands, depending on how far qualification has progressed:

qualification_statusHow it arisesWhat you do
pending_authorizationSurvey done, hold not yet signaled.Place the hold, then POST /api/v2/shop_pay/truemed_sessions/{id}/auth_holds/create.
pending_truemed_reviewReview already running: the hold was signaled before this read.Wait for the terminal webhook. Any pre-approved amount is already final.
approvedThe hold was signaled and review finished, both before this read.Go straight to capture.
rejectedReview rejected the request, or every item in the cart is ineligible.Capture any pre-approved portion (preapproved_amount_cents), release the rest, and collect the balance as an ordinary non-HSA/FSA order.
pending_user_intakeThe shopper abandoned the survey and navigated back manually (rare).Re-offer the survey redirect, or collect as an ordinary order.
canceledYou canceled the session while the shopper was out. A canceled session stops accepting surveys and holds.Collect as an ordinary order. Create a new session to retry the HSA/FSA path.

Before you start

Every request carries a platform API key (x-truemed-api-key) and the Shopify shop GID (x-truemed-shop-id), which selects the store your key acts on. The examples below use the production base URL https://api.truemed.com.

Amounts are always integer cents. The truemed_session_id from the create response identifies the session in every later call.

Walk through a checkout

1

Create the session

POST /api/v2/shop_pay/truemed_sessions/create with the cart, the shopper’s details, an idempotency_key scoped to your shop, and the return_url for Truemed to send the shopper back to after the survey.

$curl -X POST https://api.truemed.com/api/v2/shop_pay/truemed_sessions/create \
> -H "x-truemed-api-key: $TRUEMED_API_KEY" \
> -H "x-truemed-shop-id: $TRUEMED_SHOP_ID" \
> -H "Content-Type: application/json" \
> -d '{
> "idempotency_key": "shop-pay-checkout-1001",
> "user_id": "shopify_customer_7001",
> "customer_email": "alex@example.com",
> "customer_name": "Alex Morgan",
> "return_url": "https://merchant.example.com/checkout/truemed-return",
> "customer_state": "CA",
> "order_items": [
> {
> "name": "Recovery Knee Brace",
> "quantity": 1,
> "price": 12000,
> "sku": "KNEE-BRACE-001",
> "item_id": "line_item_1",
> "amount_details": {
> "discounts_details": [{"name": "WELCOME10", "amount_cents": 1000, "display_level": "line_item"}],
> "additional_charges_details": [{"name": "Tax", "amount_cents": 850, "display_level": "tax"}]
> }
> }
> ]
> }'

Report the order’s shipping charge in the optional cart_shipping block, alongside order_items. Include tax_cents when you charge shipping tax separately:

1"cart_shipping": {"amount_cents": 700, "tax_cents": 60}

cart_shipping covers all shipping on the order, so leave shipping out of the line items’ amount_details. Truemed adds it to the authorized total and shows it in the receipt’s Shipping row. It sits outside the eligibility split, so a fully pre-approved cart with shipping still skips intake.

Truemed always creates the session, even for a cart with nothing eligible, and the response is where you read the outcome. cart_info splits the cart into eligible, ineligible, and pre-approved amounts, and reports cart shipping in shipping_amount_cents and shipping_tax_cents. Both shipping fields are 0 when the cart has no shipping.

1{
2 "truemed_session_id": "ts_01JZ7D4M3A8B9C0D1E2F3G4H5J",
3 "cart_info": {
4 "eligible_amount_cents": 11850,
5 "ineligible_amount_cents": 0,
6 "preapproved_amount_cents": 0,
7 "shipping_amount_cents": 0,
8 "shipping_tax_cents": 0
9 },
10 "qualification_info": {
11 "qualification_status": "pending_user_intake"
12 },
13 "redirect_url": "https://app.truemed.com/survey/ts_01JZ7D4M3A8B9C0D1E2F3G4H5J"
14}
2

Send the shopper to the survey

When redirect_url is present, redirect the shopper there to complete the health survey and establish their LMN. Truemed returns them to the return_url you supplied.

A null redirect_url means there’s nowhere to send them, and qualification_info.qualification_status says why:

  • rejected, with eligible_amount_cents and preapproved_amount_cents both 0: every item is HSA/FSA-ineligible, so there’s no qualification path. Collect payment on your own rail as a normal order.
  • pending_authorization: no intake is needed, because the cart is fully pre-approved or because the shopper’s existing LMN covers it. Neither case emits a truemed_session.pending_authorization webhook, since there’s no intake to complete. The create response is your signal to place the hold.
3

Read the session before you take the card

Fetch the session with GET /api/v2/shop_pay/truemed_sessions/{truemed_session_id} and read qualification_info.qualification_status. That status decides what happens next; the return-gate table covers every value. approved means review already resolved, so capture. rejected means there’s no HSA/FSA path, so collect as an ordinary order.

In the common case the status reads pending_authorization, and the session waits there for you: signaling the authorization hold (or reporting a capture) starts clinical review. Size the hold from cart_info.eligible_amount_cents, the create-time estimate. The qualified amounts land on the session’s line items at terminal review and can differ from that estimate. When both the eligible and pre-approved amounts are 0, there’s no HSA/FSA path to pursue.

Place the hold on the shopper’s card on your own rail, then signal it with POST /api/v2/shop_pay/truemed_sessions/{truemed_session_id}/auth_holds/create. The call takes no request body. Signal it only once intake is resolved: a hold signaled while the session still reads pending_user_intake returns a 400. The response echoes qualification_status, which reads pending_truemed_review on an LMN cart, since signaling the hold starts review, or approved on a cart that needs no review.

A webhook-driven integration can drive the hold off truemed_session.pending_authorization, which fires the moment intake completes, and use the read above for reconciliation.

4

Capture when review finishes

Clinical review resolves asynchronously and ends with exactly one truemed_session.truemed_review_complete event. Subscribe to it, and to truemed_session.pending_authorization if you drive the hold off the webhook, so you never need to poll.

The payload points at the session rather than carrying a full snapshot:

1{
2 "webhook_delivery_id": "dlv_f3a91c0b7d2e485fa6b19c04d7e2513a",
3 "event_type": "truemed_session.truemed_review_complete",
4 "data": {
5 "truemed_session_id": "ts_01JZ7D4M3A8B9C0D1E2F3G4H5J",
6 "user_id": "shopify_customer_7001",
7 "qualification_status": "approved",
8 "is_testing": false,
9 "created_at": "2026-07-01T18:30:00Z"
10 }
11}

Fetch GET /api/v2/shop_pay/truemed_sessions/{truemed_session_id} and read qualification_status, then size the capture from each line’s eligible_amount_cents and preapproved_amount_cents. Review decides how much of each item qualifies, so the per-item split is what sizes the capture, not the create-time cart_info.eligible_amount_cents estimate.

  • On approved, capture the hold for the sum of the qualified line amounts (eligible_amount_cents + preapproved_amount_cents) and report it.
  • On rejected, review has zeroed the LMN-dependent eligible portion. Capture any preapproved_amount_cents, since pre-approved items never needed the LMN, release the rest, and collect the balance as an ordinary non-HSA/FSA sale.

Every order item on the session detail carries three amount buckets: eligible_amount_cents, preapproved_amount_cents, and ineligible_amount_cents. They’re mutually exclusive and sum to the line total once eligibility is known. eligible_amount_cents covers the LMN-dependent portion alone, so a pre-approved line reports its amount under preapproved_amount_cents and 0 eligible.

The buckets evolve with the session. Before the shopper completes the survey, the eligible/ineligible split is unknown and both fields read null, leaving only the pre-approved portion visible. After the survey they reflect the catalog split, and after review they’re final. A rejection moves the LMN-dependent eligible amount into ineligible and leaves any pre-approved amount alone. The values survive cancellation, so eligible_amount_cents > 0 on a canceled session’s items means the session was approved before it was canceled.

If you miss a delivery, the same read returns the full qualification state, so use it to reconcile.

5

Report the capture

Once you’ve captured the hold, report the charge with POST /api/v2/shop_pay/truemed_sessions/{truemed_session_id}/capture. Send a capture_info block describing the settled charge and an idempotency_key scoped to your shop.

Truemed derives the captured amount from the session’s order items, so there’s no amount field in the request; item_details identifies which lines you captured. When a line carried amount_details at creation, its capture entry repeats that accounting: a value for every original component, where 0 claims none of it, plus any new entries. Renaming, re-leveling, or dropping an original component is rejected. That decomposition keeps the HSA/FSA receipt’s line, order, tax, and shipping presentation correct after partial captures.

$curl -X POST https://api.truemed.com/api/v2/shop_pay/truemed_sessions/$TRUEMED_SESSION_ID/capture \
> -H "x-truemed-api-key: $TRUEMED_API_KEY" \
> -H "x-truemed-shop-id: $TRUEMED_SHOP_ID" \
> -H "Content-Type: application/json" \
> -d '{
> "idempotency_key": "shop_pay_charge_1001",
> "capture_info": {
> "capture_time": "2026-06-30T16:00:00Z",
> "card_holder_name": "Alex Morgan",
> "last4": "4242",
> "merchant_descriptor": "MERCHANT SHOP",
> "network": "visa",
> "postal_code": "94105",
> "network_transaction_id": "visa_txn_123456789"
> },
> "item_details": [{
> "item_id": "line_item_1",
> "quantity": 1,
> "total": 11850,
> "amount_details": {
> "discounts_details": [{"name": "WELCOME10", "amount_cents": 1000, "display_level": "line_item"}],
> "additional_charges_details": [{"name": "Tax", "amount_cents": 850, "display_level": "tax"}]
> }
> }]
> }'

The response confirms what Truemed recorded: the session’s cumulative captured amount and the capture id.

1{
2 "truemed_session_id": "ts_01JZ7D4M3A8B9C0D1E2F3G4H5J",
3 "captured_amount_cents": 11850,
4 "capture_id": "mrt_9001"
5}
6

Report refunds and cancellations

Issue refunds and cancellations on your own rail first, then report them so the session history stays accurate. Capture is the dividing line: cancel before it, refund after it. Once a session has captured money, giving it back is a refund.

Refund: POST /api/v2/shop_pay/truemed_sessions/{truemed_session_id}/refunds/create with an idempotency_key and refund_amount_cents, the authoritative refunded amount. Add refund_line_items (item_id and quantity) to classify how much of the refund was HSA/FSA-eligible, or omit them for an amount-only refund. A refund cannot exceed the session’s captured amount net of prior refunds.

Cancel: POST /api/v2/shop_pay/truemed_sessions/{truemed_session_id}/cancel when the shopper never finished or you withdrew the offer, any time before a capture is reported. Cancel takes no body and is idempotent, so re-canceling a canceled session is a safe no-op. Canceling a session that already captured returns a 400.

Authorization holds

You place the authorization hold on the card ahead of clinical review, then signal it to Truemed with POST /api/v2/shop_pay/truemed_sessions/{truemed_session_id}/auth_holds/create. The call is a status notification and takes no request body.

Truemed accepts the hold once intake is resolved, so wait for pending_authorization before signaling; a hold signaled while the session still reads pending_user_intake returns a 400. On a cart that needs an LMN, signaling the hold starts clinical review and the session advances to pending_truemed_review. A fully pre-approved cart has nothing to review, so it goes straight to approved. You capture the hold after approval and release it if the shopper is rejected.

Returning customers and LMN reuse

An LMN stays valid for about a year, so a shopper who qualified in March can skip the survey in June. When Truemed already holds a verified LMN that covers the cart, the session reuses it at creation: the create response comes back with qualification_status = pending_authorization and redirect_url = null, and your authorization hold is the only remaining gate.

Send a stable user_id on every session you create, your durable identifier for the shopper. A Shopify customer id works well. It ties a shopper’s sessions together for reconciliation, and it’s what the returning-customer match keys off as that lands.

Matching currently keys off the shopper’s checkout email, so a shopper who checks out under a new email is treated as new. Keying it off the stable user_id you send is a fast-follow. Branch on the create response as described below and your integration picks up the wider match with no changes.

qualification_status and redirect_url together cover the three ways a create can land:

Casequalification_statusredirect_url
Reusable LMN found, or a cart that needs no LMNpending_authorizationnull
Intake requiredpending_user_intakesurvey URL
Nothing in the cart is eligiblerejectednull

A reused LMN needs no clinical review, and there’s no intake to complete, so this path emits neither webhook. After signaling the hold, read the session with GET /api/v2/shop_pay/truemed_sessions/{truemed_session_id}: it reads approved once the hold is accepted and the reused LMN is attached to it. The hold response itself can read pending_truemed_review, because that attachment lands just after the call returns. Capture off the read.

Pre-approved carts

Some HSA/FSA-eligible products are pre-approved: they qualify without an LMN, so there’s no survey to take and no clinical review to wait for. When every eligible item in a cart is pre-approved, the session skips intake. The create response comes back with qualification_status = pending_authorization and redirect_url = null, and your authorization hold is the only remaining gate.

truemed_session.pending_authorization fires when survey intake completes, so a fully pre-approved cart never emits it. Place the hold off the create response, which already carries qualification_status = pending_authorization.

A cart with any LMN-dependent item walks the full ladder instead: pending_user_intake → survey → pending_authorization → hold → pending_truemed_review → terminal. A pure pre-approved cart never enters pending_truemed_review, so the hold signal moves it straight to approved. Known-ineligible items in a mixed cart sit in the ineligible bucket on the per-item split and leave the status alone.

Both paths use the same branching you already have: redirect_url at create, approved before capture. A pre-approved line’s amount sits in preapproved_amount_cents with eligible_amount_cents at 0, and capture sizing stays the sum of the two.

Reconciliation and listing sessions

GET /api/v2/shop_pay/truemed_sessions lists your channel’s sessions, newest first, for reconciliation and dashboards. Filter with user_id, qualification_status, search, and the created_* and captured_* date ranges. Page with page and page_size (default 30, max 100).

Each row is a summary: session id, cart and qualification info, and timestamps. That’s enough to drive most reconciliation on its own. For line items, captures, refunds, and auth holds, fetch the session by id with GET /api/v2/shop_pay/truemed_sessions/{truemed_session_id}.

Session statuses

qualification_status summarizes the HSA/FSA eligibility decision:

StatusMeaning
pending_user_intakeSession created; the shopper hasn’t completed intake (the survey) yet.
pending_authorizationIntake is done, or was never needed for a pre-approved cart. The session waits until you signal a hold or report a capture. On an LMN cart that starts review; a pure pre-approved cart goes straight to approved.
pending_truemed_reviewIntake is complete and the order is under clinician review.
approvedReview approved everything that entered review, so the shopper qualified. Items already ineligible at create stay ineligible; read the per-item split on order_items.
rejectedNo qualification: review declined the request, an initially-eligible item failed review, or every item in the cart is HSA/FSA-ineligible.
canceledThe session was canceled before capture. It stops accepting surveys and holds.

You own the payment state on your own rail. The capture, refund, and cancel responses confirm each action you report with recorded amounts and ids, so there’s no separate Truemed-side money status to reconcile against.

Idempotency and safe retries

Create, capture, and refund each take an idempotency_key scoped to your shop. Replaying a key with identical contents returns the originally recorded result instead of double-recording it, so retries after a network blip are safe. Reusing a key with a different payload returns a 400. Cancel and the auth-hold signal carry no body and are idempotent on their own, so repeating them is a safe no-op.

Full request and response schemas for every endpoint live in the API Reference.