Nov 13, 202626 min read

Returns and RMA Backend: Request, Label, Inspect and Refund Once

Design the RMA state machine, partial returns, refund versus store credit, inspection and restocking, idempotent refunds, carrier label webhooks and fraud checks, then implement the flow in TypeScript.

Illustration for Returns and RMA Backend: Request, Label, Inspect and Refund Once

A customer who sends an item back expects a refund after the warehouse has received it, not at the moment they submit the form. If the refund runs when the return is requested, you pay out for parcels that never arrive. If it waits on a manual spreadsheet, the same order is easy to refund twice, a partial return can be over-credited, and a late parcel can be paid out after the request has already expired.

A returns and RMA backend is the system that owns that lifecycle: eligibility, a prepaid label, carrier scans, warehouse receipt, inspection, a single payout or store credit, restocking of sellable units, and a daily sweep of requests that never arrived. The hard parts are the state machine, the money movement and the ways the process is abused, not the choice of cloud resources.

This guide starts with those design decisions, then shows a complete hand-built path on AWS, then the same flow in TypeScript with Encore in your own AWS or GCP account.

The Returns Backend at a Glance

JobPatternHand-built on AWSWith Encore in your AWS account
State machineExplicit statuses and legal transitions, never a spreadsheet columnRDS for PostgreSQLEncore database on RDS
Partial returnRemaining quantity per order line, inspected qty may be lowerSame databaseSame database
LabelPrepaid label stored as an object, sent as a signed URLS3 bucketEncore bucket on S3
Carrier scansSigned webhook, recorded once by event IDAPI endpoint and a processed-events tableRaw endpoint and the same table
Inspection and restockPer-line disposition; only sellable units increment on_handSame transaction as the payoutSame transaction as the payout
Refund or creditOne payout row per request, or one credit grant, never both by accidentSecrets Manager plus the payments API, or a ledger tableEncore secret plus the same call
Fraud holdVelocity, high-value and mismatch checks before auto-approvalApplication rules and a hold statusThe same rules in the request handler
NoticesLabel ready, refunded, credited or rejected, each sent onceSNS topic and SQS queuePub/Sub topic and subscription
ExpiryDaily sweep of approved or in-transit returns that never arrivedEventBridge Scheduler or an ECS scheduled taskCron job, Encore platform Managed

The RMA State Machine

Treat every return request as a record that moves through a small set of statuses, with the allowed transitions written down and enforced in SQL or in the application. A loose status text field that any handler can overwrite is how a request is refunded twice, or how a late parcel is paid out after expiry.

A practical set of statuses is:

StatusMeaningTypical next steps
requestedThe customer submitted the form. Eligibility has been checked, but a reviewer or an automatic rule has not yet approved it.approved, rejected, hold
approvedThe request is eligible. A label has not been issued yet.label_issued, rejected, expired
label_issuedA prepaid label is stored and the customer has a download URL.in_transit, received, expired
in_transitA carrier scan shows the parcel has been picked up or is moving toward the warehouse.received, expired, hold
receivedThe warehouse has dock-scanned the parcel. Inspection has not finished.refunded, credited, rejected, hold
refundedMoney has been sent back to the original payment method.Terminal, except for a separate adjustment
creditedStore credit has been granted instead of a card refund.Terminal, except for a separate adjustment
rejectedThe request was refused, or inspection refused every line.Terminal for this request; the customer may open a new one
expiredThe label window passed with no warehouse receipt.Terminal; a late parcel goes to a manual queue, not back to received automatically
holdFraud, abuse or an exception needs a person to look at the request.approved, rejected, or a manual payout after review

A few transition rules are easy to get wrong and worth spelling out:

  • Do not skip received, because a carrier DELIVERED event means the parcel reached the warehouse address, not that a clerk has opened the box, and refund and restock wait on the dock scan and on inspection.
  • label_issued may jump to received when the warehouse scan arrives before any carrier event. That is normal for short-haul returns.
  • expired is terminal for the automatic path. A parcel that arrives afterwards is recorded, then placed on a manual queue. Reopening the request from the webhook is how you refund a return you already wrote off.
  • rejected and expired free the reserved quantity on each order line, so the customer can try again. refunded and credited do not.
  • Once a request is refunded or credited, the only safe change is a separately recorded adjustment, not a second pass through the inspect endpoint.

Keep the current status and a short movement history, which is what you read when a customer, a warehouse clerk and a payments row disagree.

A Data Model That Enforces the Rules

The remaining-quantity check belongs in SQL so two concurrent requests cannot both take the last unit of a line. Under Read Committed, a second INSERT that reads the same order line waits, then re-checks the remaining quantity, as the transaction isolation docs describe.

-- returns/migrations/1_returns.up.sql CREATE TABLE orders ( id BIGSERIAL PRIMARY KEY, customer_id TEXT NOT NULL, delivered_at TIMESTAMPTZ NOT NULL ); CREATE TABLE order_items ( id BIGSERIAL PRIMARY KEY, order_id BIGINT NOT NULL REFERENCES orders (id), sku TEXT NOT NULL, qty INT NOT NULL CHECK (qty > 0), unit_cents INT NOT NULL CHECK (unit_cents >= 0), window_days INT NOT NULL DEFAULT 30 ); CREATE TABLE stock ( sku TEXT PRIMARY KEY, on_hand INT NOT NULL CHECK (on_hand >= 0) ); CREATE TABLE return_requests ( id BIGSERIAL PRIMARY KEY, order_id BIGINT NOT NULL REFERENCES orders (id), customer_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'requested' CHECK (status IN ( 'requested', 'approved', 'label_issued', 'in_transit', 'received', 'refunded', 'credited', 'rejected', 'expired', 'hold' )), reason TEXT NOT NULL, refund_method TEXT NOT NULL DEFAULT 'original_payment' CHECK (refund_method IN ('original_payment', 'store_credit')), label_key TEXT, received_at TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL, hold_reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE return_items ( request_id BIGINT NOT NULL REFERENCES return_requests (id), order_item_id BIGINT NOT NULL REFERENCES order_items (id), qty INT NOT NULL CHECK (qty > 0), qty_accepted INT, disposition TEXT CHECK (disposition IN ('restock', 'damaged', 'reject', 'missing')), PRIMARY KEY (request_id, order_item_id) ); CREATE TABLE processed_events ( event_id TEXT PRIMARY KEY, event_type TEXT NOT NULL, request_id BIGINT REFERENCES return_requests (id), received_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE refunds ( request_id BIGINT PRIMARY KEY REFERENCES return_requests (id), amount_cents INT NOT NULL, provider_refund_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE store_credits ( request_id BIGINT PRIMARY KEY REFERENCES return_requests (id), customer_id TEXT NOT NULL, amount_cents INT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE sent_notices ( request_id BIGINT NOT NULL REFERENCES return_requests (id), kind TEXT NOT NULL, PRIMARY KEY (request_id, kind) ); CREATE INDEX return_requests_expiring ON return_requests (expires_at) WHERE status IN ('approved', 'label_issued', 'in_transit'); CREATE TABLE warehouse_staff ( user_id TEXT PRIMARY KEY );

refunds and store_credits both use the request ID as the primary key, so a retry cannot grant the same payout twice, and a request cannot be both refunded to the card and credited to the ledger unless you add an explicit split later. window_days sits on the order line so sale items and international orders can use a different window from the default.

Partial Returns

Customers often send back one size or one unit from a multi-item line, and inspection often accepts fewer units than the request claimed. The backend has to handle both without letting the leftover quantity disappear.

When the request is opened, subtract quantities already on non-rejected, non-expired, non-hold returns for that order line, and refuse the request if the remaining quantity is too small. A unique constraint on (order_item_id) is not enough: a rejected request should leave the customer free to try again, and a partial return should leave the leftover units available for a second request.

At inspection, record qty_accepted separately from the requested qty. The payout is qty_accepted * unit_cents for lines that are restocked or written off as damaged, and zero for rejected or missing units. A request can therefore end as a partial refund even when every line on the original request is present in the parcel.

The mixed-parcel policy has to be decided up front: one common rule is to pay the sum of the accepted lines and close the request, which is what the sample code later in this guide does, and the other is to move a mixed parcel to hold for a second review, which is safer for high-value goods and worse for customer waiting time. Missing units, where the carrier delivered a box that does not contain every requested item, should follow the same path as a reject for the absent quantity: no payout, no restock, and a fraud note if it happens often for the same customer.

Refund Versus Store Credit

The inspect step should not assume that every accepted return is a card refund. Two settlement methods show up in almost every returns policy:

  • Refund to the original payment method. This is what the customer usually wants. It is also the slower path. The payment provider can decline a refund if the original charge is old, the card has been replaced, or the acquiring bank has already settled a chargeback on the same payment. Shipping is often excluded, and a restocking fee may be deducted, both of which belong in the amount you send to the provider, not in a later adjustment.
  • Store credit. A ledger row increases the customer's credit balance, usually immediately, and is spent on a later order. Merchants offer it when the return window has just closed, when the original payment can no longer be refunded, or when the customer prefers a faster settlement. Credit still needs the same idempotency as a card refund: one store_credits row per request, and the same row is the lock if the grant is retried.

Pick the method when the request is opened, or let the customer choose, and store it on the request as refund_method. Do not decide it only at inspect time unless your policy explicitly allows a last-minute switch. Switching after a refund row already exists is how you grant credit on top of a card payout.

A split settlement, part refund and part credit, is rare and should be a deliberate second feature, with two amount columns and two idempotency keys, not a reuse of the single-row tables above.

Inspection and Restocking

Inspection is the point where warehouse truth overrides the customer's claim. A clerk, or an inbound-scan flow, marks each line with a disposition:

  • restock if the item can be sold again, and increment on_hand in the same transaction that records the payout, so a later payout failure rolls back and does not inflate stock.
  • damaged if the customer is still owed money or credit, but the unit should not go back on sale: write it off, because a damaged unit that still increments on_hand is a later oversell.
  • reject if the item is the wrong SKU, is unworn-policy ineligible, or fails a serial-number check. No payout for that quantity. Some merchants ship the item back; others keep it. Either way, the request only moves to rejected when every line is rejected.
  • missing if the box arrived short, with no payout, no restock, and a flag for the fraud checks below.

Photographing high-value items at intake and storing those images next to the label objects gives you evidence when a customer disputes the disposition. Serial or IMEI comparison against the original shipment belongs in this step, not in the request form, because the customer cannot attest to a match you have not seen.

Do not restock before inspection, and do not restock from a carrier DELIVERED event. The parcel on the dock is not yet a sellable unit.

Idempotent Refunds Against the Payment Provider

Double payouts come from retries, not from a second click in a happy path. The inspect handler, the worker that calls the provider, and the provider's own at-least-once webhooks can all run more than once for the same request.

The durable lock is a refunds row whose primary key is the request ID, inserted with ON CONFLICT DO NOTHING before the provider is called. The HTTP call then carries an Idempotency-Key built from that request ID, for example return-1842. Stripe documents that idempotent requests retried with the same key return the saved response of the first one, and that those keys may be pruned after at least 24 hours. Your database row is therefore the lock that still works a week later; the provider key only protects the window in which a crashed worker retries immediately.

A few rules keep that pattern honest:

  • The retry must send the same amount and currency as the first call. A different body with the same key is an error at the provider, not a second refund.
  • If the provider call succeeds and the follow-up UPDATE of provider_refund_id fails, the next retry sees the existing refund row and repeats the call with the same key, then stores the returned id.
  • Do not call the provider from inside a tight webhook timeout if the refund can take longer than the warehouse or carrier is willing to wait. Insert the refund row, enqueue the work, and let a worker make the HTTP call.
  • The same pattern applies to store credit: insert the store_credits row first, then update the customer's balance. The primary key, not a unique constraint on a later ledger entry, is what stops a double grant.

Carrier Label Webhooks

Issuing the label and hearing back from the carrier are two different jobs. After approval, the service calls the carrier, stores the label PDF under a stable object key derived from the request ID, and sends the customer a signed download URL. A retry overwrites the same object rather than creating a second label that the carrier will also bill.

The carrier then posts tracking events to a webhook: label created, picked up, in transit, delivered, exception, or return to sender. Handle those the way you would handle a payments webhook. Verify the signature over the raw body, insert the event ID into processed_events with ON CONFLICT DO NOTHING, and only then move the request. Stripe's webhook docs describe at-least-once delivery with live-mode retries for up to three days, and a carrier feed should be treated the same way.

Map events to statuses conservatively:

  • Picked up or in transit moves label_issued to in_transit.
  • Delivered is a signal rather than a receipt: it can notify the warehouse that a parcel is due, but it does not mark the request received and it does not start a refund.
  • Exception or return-to-sender moves the request to hold and notifies a clerk, and does not refund.
  • A second event with the same ID is a no-op. A later event with a new ID may move the request forward, never backward from received, refunded or credited.

Label expiry is a separate concern from request expiry: signed download URLs should last long enough for the customer to print the label, often several days, and a dedicated endpoint can mint a fresh URL if the first one lapses. The prepaid label at the carrier may have its own validity window; once that passes, a new label is a new carrier call, still stored at the same object key if your contract allows a replacement, or at a versioned key if it does not.

Fraud and Abuse Checks

A returns flow that auto-approves every eligible request will be used as a way to get a free rental, an empty-box refund or a second payout on the same line. The checks do not have to be a machine-learning model. A short list of rules, applied when the request is opened and again at inspection, catches most of the obvious cases and sends the rest to hold.

  • Velocity. Count open and recently completed requests for the same customer, ship-to address and payment method. A second return in a week may be fine; a fifth for high-value SKUs in the same period should wait for review.
  • High-value first orders. A new customer who returns most of a large first order, especially after a short time at the delivery address, is a common abuse pattern. Auto-approval can still run for low-value items while this case goes to hold.
  • Serial or SKU mismatch. Inspection that finds a different IMEI, a different size or a different product than the outbound shipment should reject the line and flag the request.
  • Wardrobing and empty-box returns. Missing tags, obvious wear, or a delivered parcel whose contents do not match the label are inspection findings, recorded as reject or missing and counted against the customer's velocity.
  • Label fraud. A label that is printed and never picked up, followed by a claim that the parcel was lost, should not refund. Require a carrier pickup or a warehouse receipt before money moves, which the state machine already does if you do not refund from label_issued.
  • Address mismatch. A return ship-from that differs from the original ship-to is sometimes legitimate, for a gift or a forwarding address, and sometimes a redirection fraud. Treat it as a hold trigger rather than an automatic reject.

Write hold_reason when a rule fires, and keep a person in the loop for holds. Automatic rejects are appropriate for a closed window or a remaining-quantity failure; they are a poor fit for fraud, where a false reject is a lost customer.

How Do You Build It by Hand on AWS?

The design above does not depend on a particular application framework. A service you build and operate yourself on AWS can implement every part of it, and many teams do.

  1. Store orders and returns. RDS for PostgreSQL holds the schema above. The API runs on ECS Fargate, or on Lambda if the webhook and inspect paths stay within their time limits, in private subnets that can reach the database. You write the IAM role, the security groups and the DATABASE_URL (or a secrets-backed equivalent) that the task uses.
  2. Issue a label. After approval, the service calls the carrier, stores the PDF in an S3 bucket and sends a presigned download URL. The task role needs write access to that bucket. You maintain the bucket policy and the lifecycle rule that expires old labels.
  3. Receive carrier and warehouse events. An HTTPS endpoint verifies the signature over the raw body and writes to processed_events. You terminate TLS, rotate the webhook secret in Secrets Manager and inject it into the task. Warehouse dock scans can use the same endpoint shape with a different signer, or a second path if the warehouse system cannot share the carrier's scheme.
  4. Inspect, refund or credit, and restock. A warehouse tool authenticates and posts dispositions to the inspect endpoint. The service inserts the refunds or store_credits row, calls the payment provider or updates the credit ledger, and increments on_hand only for restocked units, in one transaction around the database writes. The provider key lives in Secrets Manager.
  5. Sweep stragglers. An EventBridge Scheduler rule, or an ECS scheduled task, runs once a day, finds approved, label_issued or in_transit requests past expires_at with no receipt, and marks them expired. Late parcels are written to a manual queue rather than being reopened by the webhook.
  6. Notify. Label, refund, credit and rejection notices go to SNS, and a worker on an SQS queue sends each one once, keyed by request and kind, which is what sent_notices enforces.

You also write the Dockerfile (or the Lambda packaging), the load balancer and the deployment pipeline, and you apply migrations as a step in that pipeline so two replicas do not race at startup. This path is explicit and portable. The cost is the connecting tissue: IAM, networking, secret injection, the scheduler and the deploy graph are yours to keep in sync with the schema as the state machine grows.

How Do You Build It with Encore?

Encore

Encore is a backend framework with built-in infrastructure for TypeScript and Go. The returns database, the labels bucket, the notices topic, the expiry job and the payment and warehouse secrets are declared in the service code. When the app deploys to your own AWS account, Encore provisions RDS, S3, SNS, SQS and Secrets Manager, as the AWS infrastructure docs list. The same code deploys to GCP with Cloud SQL, Cloud Storage, Pub/Sub and Secret Manager. Encore provisions an IAM role for the service, or a service account on GCP. Bookshop.org is an ecommerce marketplace that moved inventory-heavy systems from a Rails monolith to Go microservices with Encore in its own GCP account.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

A coding agent working in this repository adds a table, a bucket or a topic in the same change as the handler, and encore run starts Postgres locally so the request, receipt and refund paths can be called before anything is pushed. Cron jobs do not run in local development or preview environments, so the expiry sweep is tested by calling its endpoint from the local dashboard.

Set Up the App

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create shop --example=ts/hello-world$ cd shop && mkdir -p returns/migrations$ encore secret set --type prod,dev,local PspApiKey$ encore secret set --type prod,dev,local WarehouseWebhookSecret$ encore secret set --type prod,dev,local AuthTokenHMAC

Secret values stay out of the repository, where a coding agent working in the code sees the secret's name but not its value. Each environment gets its own provider key and webhook secret. Put the schema from the data model section in returns/migrations/1_returns.up.sql.

Request, Receive, Inspect and Refund

The handlers below follow the state machine and the remaining-quantity rule, issue a stable label object, record a warehouse receipt once, and settle an original-payment refund idempotently. Store credit uses the same shape against store_credits instead of refunds. Inspection sits behind the same signed-token auth handler as the rest of the warehouse tools:

// returns/auth.ts import { APIError, Gateway, Header } from "encore.dev/api"; import { authHandler } from "encore.dev/auth"; import { secret } from "encore.dev/config"; import { createHmac, timingSafeEqual } from "node:crypto"; const authTokenHMAC = secret("AuthTokenHMAC"); interface AuthParams { authorization: Header<"Authorization">; } export interface AuthData { userID: string; } export const handler = authHandler<AuthParams, AuthData>(async (params) => { const token = params.authorization?.replace("Bearer ", "") ?? ""; // Tokens are "<id>.<unix-expiry>.<hex hmac>". In production, verify a JWT // against your IdP JWKS (signature, issuer, audience, expiry) and return the subject. if (!authTokenHMAC()) throw APIError.unauthenticated("invalid token"); const parts = token.split("."); if (parts.length !== 3) throw APIError.unauthenticated("invalid token"); const [userID, expStr, sig] = parts; const exp = Number(expStr); if (!userID || userID.includes("/") || userID.includes(" ") || !Number.isFinite(exp) || exp * 1000 <= Date.now()) { throw APIError.unauthenticated("invalid token"); } const expected = createHmac("sha256", authTokenHMAC()).update(`${userID}.${expStr}`).digest("hex"); const a = Buffer.from(sig); const b = Buffer.from(expected); if (a.length !== b.length || !timingSafeEqual(a, b)) { throw APIError.unauthenticated("invalid token"); } return { userID }; }); export const gateway = new Gateway({ authHandler: handler });

The inspect handler then checks that the caller is warehouse staff before it loads the return:

// returns/returns.ts import { api, APIError } from "encore.dev/api"; import { secret } from "encore.dev/config"; import { CronJob } from "encore.dev/cron"; import { Subscription, Topic } from "encore.dev/pubsub"; import { Bucket } from "encore.dev/storage/objects"; import { SQLDatabase } from "encore.dev/storage/sqldb"; import { getAuthData } from "~encore/auth"; import { createHmac, timingSafeEqual } from "node:crypto"; const db = new SQLDatabase("returns", { migrations: "./migrations" }); const pspApiKey = secret("PspApiKey"); const warehouseSecret = secret("WarehouseWebhookSecret"); // Private bucket in your account. Labels are fetched with a signed URL. const labels = new Bucket("return-labels", { versioned: false }); export interface ReturnNotice { kind: "label_ready" | "refunded" | "credited" | "rejected" | "expired"; requestId: number; customerId: string; } export const notices = new Topic<ReturnNotice>("return-notices", { deliveryGuarantee: "at-least-once", }); const RETURN_WINDOW_DAYS = 30; const LABEL_TTL_SECONDS = 7 * 24 * 3600; interface ReturnItemInput { orderItemId: number; qty: number; } export const requestReturn = api( { expose: true, method: "POST", path: "/orders/:orderId/returns" }, async ({ orderId, customerId, reason, items }: { orderId: number; customerId: string; reason: string; items: ReturnItemInput[]; }): Promise<{ requestId: number; labelUrl: string }> => { if (!reason || items.length === 0) { throw new Error("reason and items are required"); } await using tx = await db.begin(); const order = await tx.queryRow` SELECT id, customer_id, delivered_at FROM orders WHERE id = ${orderId}`; if (!order || order.customer_id !== customerId) { throw new Error("order not found"); } const deliveredAt = new Date(order.delivered_at as string); const windowMs = RETURN_WINDOW_DAYS * 24 * 3600 * 1000; if (Date.now() - deliveredAt.getTime() > windowMs) { throw new Error("return window has closed"); } const created = await tx.queryRow` INSERT INTO return_requests (order_id, customer_id, reason, expires_at, status) VALUES ( ${orderId}, ${customerId}, ${reason}, now() + interval '21 days', 'approved' ) RETURNING id`; const requestId = Number(created!.id); for (const item of items) { if (item.qty <= 0) throw new Error("qty must be positive"); const orderItem = await tx.queryRow` SELECT id FROM order_items WHERE id = ${item.orderItemId} AND order_id = ${orderId} FOR UPDATE`; if (!orderItem) throw new Error(`unknown order item ${item.orderItemId}`); const ok = await tx.queryRow` INSERT INTO return_items (request_id, order_item_id, qty) SELECT ${requestId}, ${item.orderItemId}, ${item.qty} FROM order_items oi WHERE oi.id = ${item.orderItemId} AND oi.order_id = ${orderId} AND oi.qty - COALESCE(( SELECT SUM(ri.qty) FROM return_items ri JOIN return_requests rr ON rr.id = ri.request_id WHERE ri.order_item_id = oi.id AND rr.status NOT IN ('rejected', 'expired') AND rr.id <> ${requestId} ), 0) >= ${item.qty} RETURNING order_item_id`; if (!ok) { throw new Error(`cannot return ${item.qty} of order item ${item.orderItemId}`); } } const key = `labels/${requestId}.pdf`; const pdf = await createLabel(requestId); await labels.upload(key, pdf, { contentType: "application/pdf" }); await tx.exec` UPDATE return_requests SET label_key = ${key}, status = 'label_issued' WHERE id = ${requestId}`; await tx.commit(); const signed = await labels.signedDownloadUrl(key, { ttl: LABEL_TTL_SECONDS }); await notices.publish({ kind: "label_ready", requestId, customerId }); return { requestId, labelUrl: signed.url }; }, ); // Warehouse dock-scan webhook. The signature covers the raw body. export const warehouseWebhook = api.raw( { expose: true, method: "POST", path: "/webhooks/warehouse" }, async (req, resp) => { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const body = Buffer.concat(chunks); const got = Buffer.from(String(req.headers["x-warehouse-signature"] ?? ""), "hex"); const want = createHmac("sha256", warehouseSecret()).update(body).digest(); if (got.length !== want.length || !timingSafeEqual(got, want)) { resp.writeHead(401).end("invalid signature"); return; } let event: { id: string; requestId: number }; try { event = JSON.parse(body.toString("utf8")); } catch { resp.writeHead(400).end("invalid json"); return; } await using tx = await db.begin(); const fresh = await tx.queryRow` INSERT INTO processed_events (event_id, event_type, request_id) VALUES (${event.id}, 'warehouse_received', ${event.requestId}) ON CONFLICT DO NOTHING RETURNING event_id`; if (fresh) { const updated = await tx.queryRow` UPDATE return_requests SET status = 'received', received_at = now() WHERE id = ${event.requestId} AND status IN ('label_issued', 'in_transit') RETURNING id`; if (!updated) { throw new Error("return is not waiting for a receipt"); } } await tx.commit(); resp.writeHead(204).end(); }, ); export const inspect = api( { expose: true, auth: true, method: "POST", path: "/returns/:requestId/inspect" }, async ({ requestId, lines }: { requestId: number; lines: { orderItemId: number; disposition: "restock" | "damaged" | "reject" }[]; }): Promise<{ status: string; amountCents: number }> => { const { userID } = getAuthData()!; await using tx = await db.begin(); const staff = await tx.queryRow` SELECT user_id FROM warehouse_staff WHERE user_id = ${userID}`; if (!staff) { throw APIError.permissionDenied("warehouse role required"); } const request = await tx.queryRow` SELECT id, customer_id, status FROM return_requests WHERE id = ${requestId} FOR UPDATE`; if (!request || request.status !== "received") { throw new Error("return is not ready for inspection"); } const items = await tx.queryAll` SELECT ri.order_item_id, ri.qty, oi.sku, oi.unit_cents FROM return_items ri JOIN order_items oi ON oi.id = ri.order_item_id WHERE ri.request_id = ${requestId}`; const submitted = new Set(lines.map((line) => line.orderItemId)); if (submitted.size !== lines.length || lines.length !== items.length) { throw new Error("every returned line needs a disposition"); } let amountCents = 0; let rejected = 0; for (const line of lines) { const item = items.find((it) => Number(it.order_item_id) === line.orderItemId); if (!item) throw new Error("unknown order item on this return"); await tx.exec` UPDATE return_items SET disposition = ${line.disposition}, qty_accepted = ${ line.disposition === "reject" ? 0 : Number(item.qty) } WHERE request_id = ${requestId} AND order_item_id = ${line.orderItemId}`; if (line.disposition === "reject") { rejected += 1; continue; } amountCents += Number(item.unit_cents) * Number(item.qty); if (line.disposition === "restock") { await tx.exec` UPDATE stock SET on_hand = on_hand + ${Number(item.qty)} WHERE sku = ${item.sku}`; } } if (rejected === items.length) { await tx.exec`UPDATE return_requests SET status = 'rejected' WHERE id = ${requestId}`; await tx.commit(); await notices.publish({ kind: "rejected", requestId, customerId: String(request.customer_id), }); return { status: "rejected", amountCents: 0 }; } const already = await tx.queryRow` INSERT INTO refunds (request_id, amount_cents) VALUES (${requestId}, ${amountCents}) ON CONFLICT DO NOTHING RETURNING request_id`; if (already) { const refundId = await chargeRefund(requestId, amountCents); await tx.exec` UPDATE refunds SET provider_refund_id = ${refundId} WHERE request_id = ${requestId}`; } await tx.exec`UPDATE return_requests SET status = 'refunded' WHERE id = ${requestId}`; await tx.commit(); await notices.publish({ kind: "refunded", requestId, customerId: String(request.customer_id), }); return { status: "refunded", amountCents }; }, ); async function chargeRefund(requestId: number, amountCents: number): Promise<string> { const resp = await fetch("https://psp.example.com/v1/refunds", { method: "POST", headers: { Authorization: `Bearer ${pspApiKey()}`, "Idempotency-Key": `return-${requestId}`, "Content-Type": "application/json", }, body: JSON.stringify({ amount: amountCents, currency: "usd" }), }); if (!resp.ok) throw new Error(`payment provider returned ${resp.status}`); const body = (await resp.json()) as { id: string }; return body.id; } async function createLabel(requestId: number): Promise<Buffer> { // Replace with the carrier's label API. The object key is stable, so a // retry of requestReturn overwrites the same PDF. return Buffer.from(`%PDF-1.4\n% return ${requestId}\n`, "utf8"); }

requestReturn locks each order line with FOR UPDATE before it checks remaining quantity, so two concurrent requests cannot both take the last unit. It writes the lines in the same transaction that opens the request, so a request that cannot take every item rolls back and leaves no orphan row. It then marks the request label_issued. inspect requires a signed token for a warehouse staff row, rejects payloads that repeat a line ID, marks the refund row first with ON CONFLICT DO NOTHING, then calls the provider with return-${requestId} as the idempotency key, so a retry after a crash sees the existing row and the provider returns the original refund. The HMAC-protected warehouse webhook stays a separate, unauthenticated path for carrier and dock-scan events.

Notices and the Expiry Sweep

const _sub = new Subscription(notices, "send-return-notice", { handler: async (event) => { const saved = await db.queryRow` INSERT INTO sent_notices (request_id, kind) VALUES (${event.requestId}, ${event.kind}) ON CONFLICT DO NOTHING RETURNING request_id`; if (!saved) return; // already sent // Send the email or push here. }, }); export const expireUnreceived = api( { expose: false, method: "POST", path: "/returns/expire" }, async (): Promise<{ expired: number }> => { const expired = await db.queryAll` UPDATE return_requests SET status = 'expired' WHERE status IN ('approved', 'label_issued', 'in_transit') AND expires_at < now() RETURNING id`; return { expired: expired.length }; }, ); const _ = new CronJob("expire-unreceived-returns", { title: "Expire unreceived return requests", schedule: "0 6 * * *", endpoint: expireUnreceived, });

The primary key on sent_notices is what makes each kind of email go out once, even when Pub/Sub redelivers the message. The expiry job is Encore platform Managed, and it does not run locally or in preview environments. Test it by calling expireUnreceived from the local dashboard.

Best fit: teams that want the state machine, labels, notices and daily sweep declared next to the handlers, provisioned in their own AWS or GCP account.

Things to Decide Early

  • Return window. Thirty days from delivery is a common starting point, but sale items, final-sale categories and international orders often need a shorter or longer one, stored per order line rather than as a single constant.
  • Approval. Auto-approve low-risk requests, or start everything in requested and have a reviewer move them to approved. Fraud holds should bypass auto-approval.
  • Partial inspection. If some lines in a parcel are rejected and others are restocked, decide whether the refund is the sum of the accepted lines, which is what the code above does, or whether a mixed parcel is held for a second review.
  • Refund versus credit. Choose when the customer picks the method, and what happens when the original payment can no longer be refunded.
  • Restock versus write-off. Damaged items can still be refunded without going back on sale. Only the restock disposition increments on_hand, so a write-off does not inflate available stock.
  • Late parcels. An expired request should not refund automatically when a parcel arrives after the window. Route those receipts to a manual queue instead of reopening the request from the webhook.
  • Label expiry. Signed download URLs should last long enough for the customer to print the label, often several days, and a dedicated endpoint can mint a fresh URL if the first one lapses.

Frequently asked questions

When should an ecommerce return be refunded?

After the warehouse has received the parcel and inspection has accepted it, not when the customer submits the request. Issuing the refund at request time pays out for parcels that never arrive, and running it from a spreadsheet makes it easy to refund the same order twice.

How do you stop a return from being refunded twice?

Store one refund row per return request and send the payment provider an idempotency key built from the request ID. Stripe documents that [idempotent requests](https://docs.stripe.com/api/idempotent_requests) retried with the same key return the saved response of the first one, so a crashed worker that repeats the call does not create a second payout. Stripe may prune those keys after at least 24 hours, so the refund row in your database is the long-term lock.

How do you keep a customer from returning the same item twice?

When the request is opened, subtract quantities already on non-rejected, non-expired returns for that order line and refuse the request if nothing remains. A unique constraint alone is not enough, because a rejected request should leave the customer free to try again and a partial return should leave the leftover units available.

How do you build a returns backend with Encore?

Declare the database, the labels bucket, the notices topic, the daily expiry job and the payment and warehouse secrets in the service code. When the app deploys to your own AWS or GCP account, Encore provisions the database, the bucket, the topic and the job, and Encore provisions an IAM role for the service, or a service account on GCP.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders