Engineering reference: the win-back campaigner architecture
The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the suppression markers, and how the holdout is assigned.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
- 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
- One Bedrock model, called once, with a JSON schema it must fill or leave null.
- Nothing always-on: no instance, no container, no provisioned capacity.
The system, by service name
- Compute
- Database
- App integration
- Security & identity
- Management
Region and account
- Region:
us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one. - Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A
devand aprodstack in the same account, with distinct resource prefixes, is the right size here. - Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.
Lambda inventory
| Function | Trigger | Does | Timeout / memory |
|---|---|---|---|
wb-intervals | EventBridge, weekly | Recomputes the median interval per customer per product | 300s / 1024 MB |
wb-detect | EventBridge, weekly | Flags lapses, runs all five suppression gates, assigns the holdout | 300s / 1024 MB |
wb-send | SQS send queue | Re-checks suppression, picks the offer rung, sends once | 15s / 512 MB |
Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.
IAM, scoped
| Role | Allowed | On |
|---|---|---|
wb-intervals-role | dynamodb:Query, dynamodb:PutItem | Read-only on orders; writes profiles |
wb-detect-role | dynamodb:Query, dynamodb:UpdateItem, sqs:SendMessage | Profiles and campaigns; the send queue |
wb-send-role | dynamodb:UpdateItem, ses:SendEmail | Campaigns; one verified identity |
No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.
DynamoDB schemas
Table: profiles
PK customer_id S
SK product_key S sku or category
orders N count; fewer than 3 means no interval
median_gap_days N median, not mean
last_order_at S 2026-05-04
seasonal BOOL set on the product, not inferred
lapsed_at S set when past 2.5x the gap
One row per customer per product. A customer with two products has
two intervals, and averaging them would describe neither.
Table: campaigns
PK campaign_id S 2026Q3_winback
SK customer_id S
state S suppressed | holdout | messaged
suppressed_by S complaint | refund | withdrawn | closed | bereaved
rung N 1..4, which offer was used
given_away N discount or delivery value, in pence
replied BOOL
ordered_at S within one of their intervals
margin N on the recovered order, not revenue
`suppressed_by` is what turns a suppression count into the finding
about seventy open complaints. Without it, it is just a number.
Inbound and outbound
- Orders are read, never written. This system has no write access to the order table at all.
- Suppression markers come from wherever they are heard — support, delivery returns, a phone call. One field, one click, and it is honoured everywhere.
- The consent record is consulted at detection and again at send, so a withdrawal in between suppresses the queued message.
- Suppression runs before segmentation, so a suppressed customer never exists in a target list that somebody could export.
The model call
- There is no model in this system. Intervals are a median, lapse is a comparison, and suppression is five lookups.
- The tempting use is scoring who is most likely to return, in order to message only them. That optimises the wrong thing and makes the holdout comparison meaningless.
- A second tempting use is generating the message per customer. The four rungs are four templates with the product name substituted, and a generated version reads as marketing in a message whose entire premise is that it is not.
- Classifying reply reasons is a defensible use, and keep the raw reply text: the specifics are the reason the reply was worth more than the order.
- The cost page assumes none, which is why messaging is the only variable band.
Things worth knowing before you build it
- Compute the median, not the mean. One long gap for a holiday moves a mean and does not move a median, and the mean version flags people who never lapsed.
- Run suppression before segmentation. Order matters here for a reason that only shows up when somebody exports an intermediate list.
- Never build an override for the permanent markers. The absence of the mechanism is the protection, and the request to add one always sounds reasonable.
- Assign the holdout at detection, never message it, and expect to defend it at month four.
- Report margin and what was given away in the same table as the recovery rate. A recovery rate alone only ever argues for a bigger discount.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts