LoopSolutions
All examples
Return Create APITypeScriptadvanced

Custom Return Portal

A working reference for building a shopper-facing return portal on Loop's public Draft Returns API, instead of embedding or reimplementing Loop's own portal. The idea: every draft-return response carries HAL-FORMS `_templates` — a live list of what's valid right now — so a UI can render itself from that instead of hard-coding a flow. Below is the architecture and the reasoning behind it, kept language-agnostic where it matters. The linked Next.js / TypeScript repo shows one concrete build end to end.

View full code on GitLab

Before you start

  • A Loop API key with Create Returns (read) and Create Returns (write) — those cover the Draft Returns API this is built on. Generate one in Loop Admin → Settings → Developer tools. Works against any Loop shop.
  • Node 20+.
  • A test order to look up — one with a single item, one with a quantity-3 line, and one with multiple distinct products are worth having on hand to see the different parts of the flow.

The pieces you need

Language-agnostic — every implementation has these moving parts.

Order lookup endpoint

Server
Starts a draft from an order number and a second identifier — email, ZIP, or phone, depending on what the shop accepts. Turns Loop's order-not-found error into copy a shopper can actually read.

Template parser

Shared
Turns a draft's _templates into simple actions: name, target, method, fields, and whether Loop needs an answer right now. Also merges templates that point at the same target, so the shopper isn't asked the same question twice.

Item grouping

Shared
Groups line items that are the same variant at the same price into one row with a quantity stepper — while keeping every unit underneath independently returnable.

Portal state

Browser
Keeps the current draft as the one source of truth on the client, and derives the available actions and item groups from it. Queues writes so two edits to the same draft can't race each other.

Action proxy

Server
Sends one or more template calls to Loop, in order, using the target and method the draft itself provided — after checking the target actually points at Loop.

Totals endpoint

Server
Wraps GET /totals and turns its two ways of saying "not ready yet" — an empty-message 200, and a bare 404 — into one simple { ready: boolean }.

Post-submit status reader

Server
Reads GET /warehouse/return/details by the return's integer ID for the timeline, label, and cancel button. None of this is in the hypermedia, so it's the one part of the app built on hand-written types and hand-built URLs.

Request inspector

Server
Logs every request and response exchanged with Loop, so the running app can show the exact traffic behind each screen — useful for seeing how the API behaves, and for debugging your own build.

The flow

What happens, in order, end to end.

  1. Shopper looks up their order

    They enter an order number plus email, ZIP, or phone. The server posts that to /draft-returns, which starts the draft and returns everything needed for the first screen.
  2. Items render as templates, not hard-coded fields

    The draft's _templates say which actions are valid right now — set a reason, choose refund vs. exchange, and so on. The item screen renders whichever of those show up, instead of assuming a fixed set of steps.
  3. Quantity collapses to one row per variant

    Units that are the same variant at the same price group into one row with a stepper. Moving the stepper adds or removes individual items behind the scenes — the shopper only sees the underlying units if they choose to split one out.
  4. Each action posts to the target the draft gave you

    Picking a reason, an outcome, or an exchange variant sends that template's target and method to the action proxy, which forwards it to Loop and gets back the whole updated draft — never a partial update.
  5. Totals appear once every item has an outcome

    The client checks /totals after each update. Loop returns 404 until every item has a return type — once it stops, the live money breakdown shows up next to the choices.
  6. Draft-level steps render generically too

    Once items are finalized, new required templates appear on the draft — credit type, return method, policy acceptance — and which ones show up depends on the shop. The same generic renderer used for items handles these too, so a new step needs no code change.
  7. Submit hands off from hypermedia to hand-built

    The review screen recaps everything, then submits. From here, the flow moves from the draft (fully hypermedia-driven) to the submitted return, which HAL-FORMS doesn't represent at all.
  8. Status page reads by return ID, not the draft

    The post-submit screen — timeline, label, cancel — is fetched by the return's integer ID from /warehouse/return/details. It's reachable any time from its own URL, so a shopper (or a support agent) can come back to it later.

The UI renders itself from the API's hypermedia

Every draft-return response carries HAL-FORMS _templates — a live list of the actions valid right now, and the fields each one takes. Reading that instead of hard-coding a flow means the UI can never offer an action the backend would reject. It sidesteps a common failure mode for return portals generally: a hard-coded flow assumes a fixed set of steps, and quietly drifts out of sync the moment a shop's configuration changes.

Nothing in this reference's code knows what set-credit-type or select-return-method mean. There's no component built just for either — they show up because the shop's real templates say they're valid right now. Add a capability to the API, or turn one on for a shop, and it appears in the portal with no frontend changes.

Follow the target, never rebuild the URL

An item-scoped template's key carries the item's public ID (set-returning-item-return-reason:abc123), but its target URL uses a different, internal one. Rebuild the URL from the key and you'll get it wrong — always follow target as-is.

That also means the client decides which Loop endpoint to hit, since it's just relaying a target / method pair the draft handed it. Worth guarding: check the target's origin is actually Loop's before following it.

client.ts (guard on template targets)ts
// Templates carry absolute target URLs — following them is the whole point,
// but a tampered target shouldn't turn this proxy into an open one.
export function assertLoopUrl(target: string): URL {
  const { baseUrl } = config();
  const url = new URL(target);
  const base = new URL(baseUrl);
  if (url.origin !== base.origin) {
    throw new Error(
      `Refusing to follow a template target outside Loop: ${url.origin}`,
    );
  }
  return url;
}

Quantity, collapsed — without giving up per-unit control

Loop has no concept of quantity. Buy three of the same item and it arrives as three separate line items — same title, same price, nothing to tell them apart. Rendered as-is, that's three identical rows asking the same question three times.

The fix: treat grouping as a display concern only. Units that share a variant and a price (price matters, since per-unit discounts can make two units of the same variant worth different amounts) collapse into one row with a stepper. The underlying units stay independent, so splitting one out for a different outcome later is free.

grouping.ts (interchangeable-unit key)ts
// Same variant, same price = interchangeable. Price is part of the key
// because a per-unit discount can make two units of one variant genuinely
// different money — collapsing those would misreport the refund.
function groupKey(item: OrderLineItem): string {
  return `${item.provider_variant_id}:${item.discounted_price}`;
}

The draft is the whole client-side state

Every mutation Loop accepts hands back the complete draft, so there's nothing to reconcile on the client — no local copy that can drift from the server's. Actions and item groups are just derived from the draft on every render.

The one catch: two overlapping writes to the same draft race, and the loser's response can silently overwrite the winner's. A global busy flag would prevent that, but it'd freeze the whole page for something as small as one click that fires three sequential calls. Queuing writes instead — chaining each call onto the last — gets the same safety without blocking anything else.

Things worth knowing

  • required means it for return type, not for reasons. A reason template stays required: true even after it's answered — treat "no more options left" as the real done signal.
  • /totals says "not ready" two different ways. A 200 with an empty-message error before any items are added, and a bare 404 once items exist but lack a return type. Neither is a real error.
  • Post-submit isn't in the hypermedia. Labels, cancel, and status aren't HAL-FORMS actions — that part of the app is hand-built types and hand-built URLs, keyed by the return's integer ID, not its UUID.
  • Writes aren't atomic. A batch of calls can partly land before one fails — adopt whatever draft comes back instead of assuming all-or-nothing.
  • The API key never reaches the browser. It's shop-wide, not shopper-scoped, so every call proxies through the server and the key stays there.

Run it yourself

Clone the repo, drop a Loop API key into .env.local, and it's running locally in a few minutes. Look up a real order on the home page to start a draft. Toggle the { } API inspector in the corner of any screen to watch the live templates and requests behind it.