Self-Processed Checkout Integration

The Truemed Session API lets you keep checkout on your own payment rail while Truemed handles the HSA/FSA side: eligibility, the shopper’s health survey, the Letter of Medical Necessity (LMN), and substantiation. You charge the card; you report the charge to Truemed; Truemed produces the compliance artifacts and 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.

How the flow works

A single session tracks one checkout from cart to settlement:

  1. Create a session with the cart. Truemed returns the cart’s eligibility breakdown and a hosted-survey URL.
  2. Send the shopper to the survey so they can establish their LMN, then bring them back to your return_url.
  3. Gate the return, then place the hold. Landing back on return_url doesn’t by itself say where qualification stands — read qualification_info.qualification_status before taking the card. approved with is_already_qualified means a returning qualified shopper: skip review and go straight to hold + capture. Otherwise place the authorization hold and signal it — the hold response’s qualification_status tells you which world you’re in: pending_truemed_review means intake is done and clinical review is running; pending_user_intake means the shopper never finished the survey, so collect payment as an ordinary, non-HSA/FSA order.
  4. Capture on the webhook. The terminal truemed_session.updated delivery is the money gate: its payload carries the authoritative qualification_status. approved or partially_approved — fetch the session, capture the hold for the qualified amount (the sum of the line items’ eligible_amount_cents + preapproved_amount_cents), and report it; rejected — void the hold and collect any remainder as an ordinary order.
  5. Report refunds — or cancel a session that never captured — as the order evolves. Once you’ve captured, the session can no longer be canceled; report refunds instead.

Truemed never moves money in this flow — you do. Every capture and refund you report describes a charge you already settled yourself.

Before you start

You’ll need a Truemed API key, passed in the x-truemed-api-key header on every request. All examples below use the production base URL https://api.truemed.com.

Amounts are always integer cents. The truemed_session_id Truemed returns at creation identifies the session in every later call.

Walk through a checkout

1

Create the session

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

$curl -X POST https://api.truemed.com/api/v2/truemed_sessions/create \
> -H "x-truemed-api-key: $TRUEMED_API_KEY" \
> -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"}]
> }
> }
> ]
> }'

A session is always minted — even for a fully ineligible cart — so the response is a uniform place to read eligibility, never a rejection. cart_info decomposes the cart into eligible, ineligible, and pre-approved amounts.

1{
2 "truemed_session_id": "ts_01JZ7D4M3A8B9C0D1E2F3G4H5J",
3 "cart_info": {
4 "cart_type": "all_eligible",
5 "eligible_amount_cents": 11850,
6 "ineligible_amount_cents": 0,
7 "preapproved_amount_cents": 0
8 },
9 "qualification_info": {
10 "qualification_status": "pending_user_intake",
11 "is_already_qualified": false
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 once they’re done.

redirect_url is null when there’s nowhere to send the shopper. Don’t hardcode the reason — read qualification_info.qualification_status to know which case you’re in:

  • rejected with cart_info.cart_type = all_ineligible — every item is HSA/FSA-ineligible, so there’s no qualification path; collect payment on your own rail as a normal, non-HSA/FSA order.
  • pending_authorization at create — a fully pre-approved cart (see Pre-approved carts): no LMN is needed, so there’s no survey to take — place the hold and signal it.
  • approved — reserved for LMN reuse (see Returning customers): the shopper already holds a covering LMN, so there’s no survey to take — go straight to hold and capture. Branching on the status now means you pick up the skip-the-survey experience the day it ships, with no integration change.
3

Gate the return, then place the hold

When the shopper lands back on your return_url, don’t take the card yet — the return alone doesn’t say where qualification stands. Fetch the session with GET /api/v2/truemed_sessions/{truemed_session_id} and read qualification_info.qualification_status:

  • approved (or partially_approved) — the session is already approved (for example, the shopper qualified on an earlier visit to this session, or — once LMN reuse ships — Truemed matched an existing LMN at creation). No review is needed: place the hold, signal it, and capture.
  • pending_authorization — intake is done (the shopper finished the survey, or the cart is fully pre-approved and never needed one), and the session is parked here until you act: nothing further happens until you signal your authorization hold (or report a capture), which is what starts clinical review. Before placing the hold, read cart_infocart_type tells you whether there’s an HSA/FSA path at all, and eligible_amount_cents (the create-time estimate) sizes the hold. The qualified amounts land on the session’s line items at terminal review and can differ from the estimate.
  • pending_user_intake — the shopper never finished the survey (uncommon at return, but real: an abandoned survey followed by manual navigation back). How to proceed is your call: re-offer the survey (the session’s redirect_url keeps working while it’s pending), or collect payment as an ordinary, non-HSA/FSA order. If they finish the survey later, the session picks up from pending_authorization.)

With intake complete, place the authorization hold on the shopper’s card on your own rail, then signal it with POST /api/v2/truemed_sessions/{truemed_session_id}/auth_holds/create (no request body). The response’s qualification_status advances to pending_truemed_review — clinical review is now running; wait for the webhook. (Holds can also be signaled before the survey finishes: the session holds at pending_user_intake and review starts automatically at survey completion.)

Prefer webhooks over the GET here too: the review-begins truemed_session.updated delivery (qualification_status = pending_truemed_review) fires the moment review starts, so a webhook-driven integration only ever needs the GET for the is_already_qualified check and for reconciliation.

4

Capture on the webhook — the money gate

Clinical review resolves asynchronously. Subscribe to the truemed_session.updated webhook: it fires when review begins, when it reaches a terminal result, and on each payment change you report — so you never poll. The terminal delivery carries the decision; the session detail carries the qualified amounts:

  • data.qualification_info.qualification_status must be approved or partially_approved before you treat the purchase as HSA/FSA-substantiated (partially_approved means the shopper qualified but part of the cart is ineligible — the per-item split tells you which lines). On rejected, void the hold and collect on a regular card as an ordinary order.
  • On approval, fetch GET /api/v2/truemed_sessions/{truemed_session_id} and read each line’s eligible_amount_cents and preapproved_amount_cents — review decides how much of each item actually qualifies, so the per-item split (not the create-time cart_info.eligible_amount_cents estimate) sizes the capture. Capture the hold for the sum of the qualified line amounts (eligible_amount_cents + preapproved_amount_cents).

Every order item on the session detail carries three amount buckets: eligible_amount_cents, preapproved_amount_cents, and ineligible_amount_cents. The buckets are mutually exclusive and sum to the line total once eligibility is known — eligible_amount_cents covers only the LMN-dependent portion, so a pre-approved line reports its amount under preapproved_amount_cents with eligible 0. They evolve with the session: before the shopper completes the survey the eligible/ineligible split is unknown (the fields are omitted) and only the pre-approved portion shows; once the survey is in they reflect the catalog split; after review they’re final — a rejection zeroes the LMN-dependent eligible portion into ineligible, while any pre-approved amount is unaffected (it never needed the LMN). 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 ever miss a delivery, the same GET returns the full qualification state — use it to reconcile, not as a required step before every charge.

5

Report the capture

Once you’ve captured the hold, report the charge with POST /api/v2/truemed_sessions/{truemed_session_id}/capture. Send a transaction_info block describing the settled charge and a merchant_transaction_id for the charge on your side.

The captured amount is derived from the session’s order items, not trusted from the request, so there’s no separate amount field — item_details identifies which lines you captured. When a line carried amount_details at creation, its capture entry must carry the same accounting: a value for every original component (0 = none of it), optionally plus new entries — never a renamed, re-leveled, or omitted one. The same rule applies to refund item_details, and it’s what keeps the HSA/FSA receipt’s line/order/tax/shipping presentation correct after partial captures and refunds.

$curl -X POST https://api.truemed.com/api/v2/truemed_sessions/$TRUEMED_SESSION_ID/capture \
> -H "x-truemed-api-key: $TRUEMED_API_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "merchant_transaction_id": "shop_pay_charge_1001",
> "transaction_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 the recorded capture — the cumulative captured amount and Truemed’s 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 to Truemed so the session history stays accurate. The dividing line is capture: cancels happen before capture, refunds happen after — once money has been captured on a session, giving it back is always a refund.

  • Refund: POST /api/v2/truemed_sessions/{truemed_session_id}/refunds/create with an idempotency_key and the itemized item_details — the refunded amount is derived from the line totals, the same way capture works, so there’s no separate amount field. Truemed records the refund against the session’s history.
  • Cancel: POST /api/v2/truemed_sessions/{truemed_session_id}/cancel when the shopper never finished or you withdrew the offer — before anything was captured. A captured session can no longer be canceled (the call returns a 400); money coming back after capture is a refund. Cancel takes no body and is idempotent — re-canceling an already-canceled session is a safe no-op.

Authorization holds

You place an authorization hold on the card ahead of clinical review, then signal it to Truemed with POST /api/v2/truemed_sessions/{truemed_session_id}/auth_holds/create. This is a status notification only (no request body): it advances the session to pending_truemed_review and triggers LMN review once the shopper has completed the survey. After approval you capture that hold (reported as your capture); if the shopper is rejected, you void it.

Returning customers and LMN reuse

Send a stable user_id — your durable identifier for the shopper (a Shopify customer id works well) — on every session you create. It’s what ties a shopper’s sessions together, and it’s the key that unlocks the returning-customer experience: an LMN is valid for about a year, so a shopper who qualified in March doesn’t have to re-take the survey in June.

When Truemed can match the user_id to an existing LMN that covers the cart, the create response comes back already decided — no survey, no review, no waiting:

The contract carries everything this needs — is_already_qualified and a nullable redirect_url — so an integration that branches on the create response’s qualification_status (redirect when there’s a redirect_url, capture-path when it’s already approved) handles returning customers automatically.

Pre-approved carts

Some HSA/FSA-eligible products are pre-approved: they qualify without a Letter of Medical Necessity, so there is 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 entirely — the create response comes back with qualification_status = pending_authorization and redirect_url = null, and the only remaining gate is your authorization hold:

Contrast with the standard flow: a cart with any LMN-dependent item walks the full ladder (pending_user_intake → survey → pending_authorization → hold → pending_truemed_review → terminal). A pure pre-approved cart never enters pending_truemed_review — there is nothing to review — so the hold signal moves it straight to approved (or partially_approved when the cart also carries ineligible items).

An integration that follows this guide handles both flows with no extra branching: you already gate on redirect_url presence at create and on approved/partially_approved before capturing. On session reads, a pre-approved line’s amount sits in preapproved_amount_cents (with eligible_amount_cents = 0 — the buckets are mutually exclusive), and capture sizing is the same sum of eligible_amount_cents + preapproved_amount_cents.

Reconciliation and listing sessions

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

Each row returns a lean summary — session id, cart and qualification info, and timestamps — enough to drive most reconciliation directly. For a session’s full detail (line items, captures, refunds, auth holds), fetch it by id with GET /api/v2/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 done — or never needed (pre-approved cart); parked until you signal a hold (or capture) — that starts review.
pending_truemed_reviewIntake is complete and the order is under clinician review.
approvedThe shopper qualified and the full cart is HSA/FSA-eligible.
partially_approvedThe shopper qualified, but part of the cart is ineligible — read the per-item split on order_items.
rejectedNo qualification: the shopper is ineligible for an LMN for these items, or every item in the cart is HSA/FSA-ineligible.
canceledThe session was canceled before capture; it stops accepting surveys and holds.

You already own the payment state on your own rail — the capture, refund, and cancel responses confirm each action you report (recorded amounts and ids), so you never have to reconcile a separate Truemed-side money status.

Idempotency and safe retries

Create and refund requests take an idempotency_key; captures are idempotent on the pair of your channel and merchant_transaction_id. Replaying the same key (or merchant transaction) returns the originally recorded result rather than double-recording, so retries after a network blip are safe. Reusing a key with a different payload returns a 400 validation error.

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