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-onlyrmar-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.
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 untilrmar-trackresumes it on a delivered scan, or TimeoutClose fires from the sweep. - Inspect — task with
waitForTaskToken. Parks until a staffer submits the condition throughrmar-inspect. - Decide — task,
rmar-decide. Produces one proposed outcome with its reason. - AwaitApproval — task with
waitForTaskToken. Parks until the returns desk taps a button viarmar-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 callsStartExecutiononrmar-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 bytracking_noand callsSendTaskSuccesson 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 intormar-ordersand the S3mirror/prefix.
Data stores, edges, and schedules
- DynamoDB (all on-demand).
rmar-returns— PKreturn_id(the RMA), GSI1 ontracking_nofor the carrier-scan resume, GSI2 onorder_no; holds the lifecycle status, the current task token, and the eligibility, inspection, and decision results.rmar-orders— PKorder_no, the mirrored orders read by eligibility.rmar-audit— PKreturn_id, SKts, 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 tormar-track. EventBridge Scheduler runs two jobs —rmar-drive-syncatrate(15 minutes)andrmar-sweepat a dailycronbefore 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-5via the Global cross-Region inference profile, invoked only byrmar-labelandrmar-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-2throughout; Bedrock Haiku 4.5 via Global inference, optional and only for words.