Skip to content

Part 7 of 7 · Win-back campaigner series ~7 min read

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

The win-back campaigner drawn with AWS service namesThree boxes across the top outside the AWS account. Orders, from the existing table. Complaints and refunds, along with the consent record. And SES outbound, carrying one message each. Inside the account, three groups. EventBridge running weekly to compute intervals and then run detection. Three Lambda functions named intervals, detect and send. And two DynamoDB tables named profiles and campaigns. A note gives the region as us-east-1, one account, and states that suppression runs before segmentation and that markers never expire.AWS ACCOUNTOrdersthe existing tableComplaints and refundsand the consent recordSES outboundone message eachEventBridge weeklyintervals,then detectionLambda x3intervals, detect, sendDynamoDB x2profiles, campaignsingroundsoutus-east-1. One account. Suppression runs before segmentation; markers never expire.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • 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 dev and a prod stack 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

FunctionTriggerDoesTimeout / memory
wb-intervalsEventBridge, weeklyRecomputes the median interval per customer per product300s / 1024 MB
wb-detectEventBridge, weeklyFlags lapses, runs all five suppression gates, assigns the holdout300s / 1024 MB
wb-sendSQS send queueRe-checks suppression, picks the offer rung, sends once15s / 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

RoleAllowedOn
wb-intervals-roledynamodb:Query, dynamodb:PutItemRead-only on orders; writes profiles
wb-detect-roledynamodb:Query, dynamodb:UpdateItem, sqs:SendMessageProfiles and campaigns; the send queue
wb-send-roledynamodb:UpdateItem, ses:SendEmailCampaigns; 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