Skip to content

Part 7 of 7 · Referral payout runner series ~7 min read

Engineering reference: the referral payout runner 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 event model, and the rule versioning.

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 referral payout runner drawn with AWS service namesThree boxes across the top outside the AWS account. Referral links, producing clicks and signups. Orders and refunds, read only. And Payments and email, covering transfers and statements. Inside the account, three groups. A Function URL for click intake and EventBridge running a daily qualify pass. Three Lambda functions named record, qualify and run. And two DynamoDB tables named events and rules. A note gives the region as us-east-1, one account, and states that events are append-only and rule versions are immutable once published.AWS ACCOUNTReferral linksclicks and signupsOrders and refundsread onlyPayments and emailtransfers, statementsFunction URL + EventBridgeclick intake,daily qualifyLambda x3record, qualify, runDynamoDB x2events, rulesingroundsoutus-east-1. One account. Events are append-only; rule versions are immutable once published.
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
  • Networking

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
rp-recordFunction URLCreates a referral, stamps the rule version, checks self-referral5s / 512 MB
rp-qualifyEventBridge, dailyLinks orders, closes refund windows, applies holds, marks payable300s / 1024 MB
rp-runEventBridge, monthlyBatches payable amounts per person, initiates transfers, sends statements300s / 1024 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
rp-record-roledynamodb:PutItem, dynamodb:GetItemEvents; read-only on rules
rp-qualify-roledynamodb:Query, dynamodb:PutItemEvents; read-only on the order table
rp-run-roledynamodb:Query, dynamodb:PutItem, ses:SendEmail, secretsmanager:GetSecretValueEvents; one verified identity; the payment provider credential

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: events

PK   referral_id       S
SK   seq               N   1, 2, 3 — never reused, never edited
     type              S   created | order_linked | held | released
                           | qualified | paid | clawed_back | declined
     at                S   2026-08-03T11:02:00Z
     actor             S   system | a named person
     reason            S   free text; required on held and declined
     amount_pence      N   on qualified, paid and clawed_back
     rules_version     S   on created only; read by everything after

GSI1: referrer_id + at  — builds one person’s statement in one query.
There is no status attribute anywhere. State is folded from the events.

Table: rules

PK   version           S   v4
     published_at      S   2026-07-12
     amount_pence      N   2000
     min_order_pence   N   5000
     window_days       N   60
     refund_days       N   14
     excluded_skus     L
     superseded_at     S   set when v5 publishes; the row never changes otherwise

Writes are create-only. There is no update path to this table in any
role, which is what makes ’paid under v4’ a fact rather than a claim.

Inbound and outbound

  • Clicks arrive at a Function URL which sets a first-party cookie and writes the created event with the current rule version.
  • Orders are read, never written. The qualifier matches orders to live referrals; it has no write access to the order table.
  • Refunds and chargebacks arrive on the same daily pass and produce clawed_back or declined events rather than deletions.
  • A hold requires a reason string. The write is rejected without one, which is how the referrer-facing message is guaranteed to have content.

The model call

  • There is no model in this system. Qualification is dates and thresholds; the fraud signals are counting rules over a window.
  • The tempting use is scoring referrals for fraud risk. At these volumes it produces an unexplainable hold, and Part 5 is entirely about why every hold needs a reason a person can read.
  • A second tempting use is drafting the hold notice. The four templates are better, because the wording of an accusation is not somewhere to introduce variation.
  • Classifying inbound disputes by type is defensible and useful, and the count by type is the signal described in Part 5.
  • The cost page assumes none, which is why messaging is the only variable band.

Things worth knowing before you build it

  • Give the rules table no update path in any IAM role. A rule version that can be edited after publication is not a stamp, it is a suggestion.
  • Fold state from events; never store a status attribute. The moment one exists, something writes to it and the log stops being the truth.
  • Require a reason on every hold and decline at the write layer. A reason field that is optional is empty exactly when it matters.
  • Batch transfers per person per run. Per-referral transfers cost more in fees than the entire rest of the system.
  • Send the statement even when the payment is zero. That statement prevents more support load than any other single thing here.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts