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 GitLabBefore 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
Serverorder-not-found error into copy a shopper can actually read.Template parser
Shared_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
SharedPortal state
BrowserAction proxy
Servertarget and method the draft itself provided — after checking the target actually points at Loop.Totals endpoint
ServerGET /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
ServerGET /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
ServerThe flow
What happens, in order, end to end.
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.Items render as templates, not hard-coded fields
The draft's_templatessay 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.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.Each action posts to the target the draft gave you
Picking a reason, an outcome, or an exchange variant sends that template'stargetandmethodto the action proxy, which forwards it to Loop and gets back the whole updated draft — never a partial update.Totals appear once every item has an outcome
The client checks/totalsafter 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.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.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.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.
// 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.
// 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
requiredmeans it for return type, not for reasons. A reason template staysrequired: trueeven after it's answered — treat "no more options left" as the real done signal./totalssays "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.