Part 7 of 7 · Return and RMA handler series ~8 min read

Engineering reference: the return and RMA handler architecture

This is the return and RMA handler with the friendly labels removed: the state machine and every state in it, the real Lambda names, the returns table and its keys, the carrier-webhook and sweep wiring, the bucket layout, and the IAM scope. If you want to build it rather than understand it, start here.

Key takeaways

  • One Standard Step Functions state machine, rmar-flow, drives every return; ten small Lambdas back its tasks.
  • Three of the states wait on a task token: AwaitInbound, Inspect, and AwaitApproval — each resumed by its own function.
  • Three DynamoDB tables on-demand: rmar-returns (with tracking and order GSIs), rmar-orders, and an append-only rmar-audit.
  • Inbound is SES plus Lambda Function URLs; carrier scans route through an EventBridge bus, and two Scheduler rules drive the sweep and the Drive sync.
  • One region, eu-west-2, one account, no API Gateway, no NAT Gateway, nothing always-on.

The architecture, for engineers

This is the same system as Part 1 with the friendly labels removed and the real resources named. Everything runs in one region, eu-west-2 (London), in one account. There is no API Gateway and no NAT Gateway; inbound HTTP arrives on Lambda Function URLs, email through SES, and the whole lifecycle of a return is one execution of a Standard Step Functions state machine that parks for free between stages.

Engineering architecture: inbound edges, the rmar-flow state machine and its states, the resume functions, the data stores, and the scheduled jobs Left column: SES inbound writing return requests to S3 and invoking rmar-intake; a Lambda Function URLs box covering the returns form, carrier webhook, inspect form, and approve buttons; an EventBridge bus rmar-events; and lower down an EventBridge Scheduler driving rmar-drive-sync every fifteen minutes and rmar-sweep daily. rmar-intake starts the execution. Centre: the Step Functions state machine rmar-flow drawn as a vertical chain of states — Eligibility (rmar-eligibility), a Choice on eligibility, RMA and label (rmar-label), AwaitInbound on a task token, Inspect on a task token, Decide (rmar-decide), AwaitApproval on a task token, and Resolve (rmar-resolve). Right of the chain: Bedrock Haiku 4.5 called by the label and resolve stages; and the three resume functions rmar-track, rmar-inspect, and rmar-approve, each issuing SendTaskSuccess into its waiting state from a carrier scan, an inspect submission, and an approval tap respectively. Far right: three DynamoDB tables on-demand — rmar-returns keyed by return_id with GSIs on tracking_no and order_no, rmar-orders keyed by order_no, rmar-audit keyed by return_id and timestamp — and an S3 bucket rmar-store with labels, photos, and mirror prefixes. The sweep can time AwaitInbound out. A bottom line notes all Lambdas are Python 3.14 on arm64, with CloudWatch Logs at 7-day retention, an SQS queue with a dead-letter queue, and an AWS Budgets alarm. AWS account · eu-west-2 SES inbound returns@ → S3 + intake Lambda Function URLs form · carrier · inspect · approve EventBridge bus rmar-events rmar-intake starts the execution Step Functions · rmar-flow Eligibility rmar-eligibility Choice: eligible? RMA & label rmar-label AwaitInbound task token Inspect task token Decide rmar-decide AwaitApproval task token Resolve rmar-resolve Bedrock Haiku 4.5 (optional) rmar-track carrier scan → resume scan rmar-inspect submit → resume form rmar-approve tap → resume tap Secrets Manager label + webhook keys rmar-returns PK return_id · GSI tracking/order read / write rmar-orders PK order_no rmar-audit PK return_id · SK ts S3 rmar-store labels/ photos/ mirror/ EventBridge Scheduler 2 schedules rmar-drive-sync every 15 min rmar-sweep daily timeout All Lambdas: Python 3.14, arm64 · SQS rmar-jobs + DLQ · CloudWatch Logs 7-day retention · AWS Budgets cost alarm
Fig 7. The return and RMA handler drawn for engineers: SES and Function URL edges, the rmar-flow state machine and its eight states, three task-token resume functions, three DynamoDB tables, one S3 bucket, and two scheduled jobs. One region, one account, no API Gateway.

The state machine

One Standard state machine, rmar-flow, started once per return by rmar-intake. Standard (not Express) because executions live for days and the long waits are free. Its states, in order:

  • Eligibility — task, rmar-eligibility. Matches the order and runs the policy checks.
  • EligibleChoice — choice. On a fail, routes to DeclineReturn, a terminal task that emails the customer the reason; otherwise continues.
  • IssueRmaAndLabel — task, rmar-label, with a retry policy; idempotent so a retry never buys a second label.
  • AwaitInbound — task with waitForTaskToken. Parks until rmar-track resumes it on a delivered scan, or TimeoutClose fires from the sweep.
  • Inspect — task with waitForTaskToken. Parks until a staffer submits the condition through rmar-inspect.
  • Decide — task, rmar-decide. Produces one proposed outcome with its reason.
  • AwaitApproval — task with waitForTaskToken. Parks until the returns desk taps a button via rmar-approve.
  • Resolve — task, rmar-resolve. Records the outcome to the audit log and notifies the customer. Terminal.

Lambda functions

Ten functions, all Python 3.14 on arm64, all with CloudWatch Logs at 7-day retention. An SQS queue (rmar-jobs, with rmar-jobs-dlq after five attempts) buffers the edge functions from the slower work behind them.

  • rmar-intake — backs the returns-form Function URL and the SES inbound rule; normalises the request and calls StartExecution on rmar-flow.
  • rmar-eligibility, rmar-label, rmar-decide, rmar-resolve — the four synchronous task states described above.
  • rmar-track — triggered by the EventBridge rule on delivered scans; finds the return by tracking_no and calls SendTaskSuccess on the AwaitInbound token.
  • rmar-inspect — backs the inspect Function URL; stores photos to S3 and resumes the Inspect token.
  • rmar-approve — backs the approve/switch/reject Function URL; resumes the AwaitApproval token with the human’s decision.
  • rmar-sweep — scheduled daily; nudges or times out returns parked past the deadline and flags genuinely lost parcels to a person.
  • rmar-drive-sync — scheduled every 15 minutes; mirrors the orders sheet and policy doc into rmar-orders and the S3 mirror/ prefix.

Data stores, edges, and schedules

  • DynamoDB (all on-demand). rmar-returns — PK return_id (the RMA), GSI1 on tracking_no for the carrier-scan resume, GSI2 on order_no; holds the lifecycle status, the current task token, and the eligibility, inspection, and decision results. rmar-orders — PK order_no, the mirrored orders read by eligibility. rmar-audit — PK return_id, SK ts, append-only, one row per state change and decision.
  • S3. One bucket, rmar-store, versioned: labels/ for label PDFs, photos/ for inspection photos, mirror/ for the Drive snapshot, and a raw-mail prefix for SES inbound with a 30-day lifecycle expiry.
  • SES. One inbound receipt rule on the returns address that writes to S3 and invokes rmar-intake; verified domain and DKIM for the RMA, approval, and outcome emails.
  • EventBridge. A custom bus, rmar-events, with one rule matching delivered scans to rmar-track. EventBridge Scheduler runs two jobs — rmar-drive-sync at rate(15 minutes) and rmar-sweep at a daily cron before business hours.
  • Secrets Manager. One secret for the carrier/label API key, one for the webhook signing key; fetched at call time, never in env vars or the policy doc.
  • Bedrock. Model id anthropic.claude-haiku-4-5 via the Global cross-Region inference profile, invoked only by rmar-label and rmar-resolve, and only for wording. Optional.

IAM scope and region

Each function gets its own execution role scoped to exactly what it touches, no wildcards. rmar-intake can read rmar-orders and call StartExecution on the one state machine; it cannot read the audit table or call Bedrock. The three resume functions hold only states:SendTaskSuccess and the narrow reads they need — rmar-track can query the tracking_no GSI and read the webhook secret, nothing more. rmar-label is the only role that can read the carrier secret and write to labels/; rmar-resolve is the only role that writes the final outcome. Only rmar-label and rmar-resolve carry bedrock:InvokeModel, scoped to the one Haiku profile. No role can delete from any table. Everything runs in eu-west-2; the only cross-Region path is Bedrock’s Global inference profile, which routes the model call and is not a data store. An AWS Budgets alarm watches the monthly spend and notifies if it drifts above a few dollars — the cheapest early signal that the label API or a loop is misbehaving.

Design rules that shaped the build

  • One state machine, one return. The whole lifecycle is one Standard execution you can inspect at any point.
  • Wait on tokens, not compute. The three pauses cost nothing; functions resume them only on a real event.
  • One job per function. Ten small Lambdas, an SQS buffer, and no function that does everything.
  • Least privilege, per role. Only the resume functions can SendTaskSuccess; only the label role reads the carrier secret.
  • State in DynamoDB, blobs in S3. Returns, orders, and audit in tables; labels, photos, and the mirror in one bucket.
  • One region, one model. eu-west-2 throughout; Bedrock Haiku 4.5 via Global inference, optional and only for words.
All posts