Skip to content

Part 7 of 7 · Subscription audit bot series ~7 min read

Engineering reference: the subscription audit bot 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 three tables, the scheduled sweeps, and the one place a model is used.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 4 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
  • 3 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 subscription audit bot drawn with AWS service namesThree boxes across the top outside the AWS account. Transaction feed, arriving as a CSV drop, an emailed statement or a read-only API. The Owner tab, read and written through the Google Sheets API. And SES outbound, carrying the renewal questions and the monthly digest. Inside the account, three groups. S3 holding the raw feeds and SQS carrying one merchant queue. Four Lambda functions named ingest, group, attribute and ask. And three DynamoDB tables named txns, subs and merchants. A note gives the region as us-east-1, one account, and states that no credential to any audited service exists anywhere in it.AWS ACCOUNTTransaction feedCSV, email, or APIOwner tabSheets API, read-writeSES outboundquestions, digestS3 + SQSfeeds,one merchant queueLambda x4ingest, group,attribute, askDynamoDB x3txns, subs, merchantsingroundsoutus-east-1. One account. No credential to any audited service exists anywhere in it.
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
  • Storage
  • Database
  • App integration

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
sa-ingestS3 ObjectCreated + SES inboundParses the feed, fingerprints each line, writes txns60s / 1024 MB
sa-groupEventBridge dailyRebuilds subscription groups from the txn history120s / 1024 MB
sa-attributeSQS merchant queueOwner lookup, cardholder fallback, one Bedrock call on new merchants20s / 512 MB
sa-askEventBridge daily + Function URLRenewal questions, the monthly digest, and the signed answer links30s / 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
sa-ingest-roles3:GetObject, dynamodb:PutItemThe feeds prefix; the txns table only
sa-group-roledynamodb:Query/PutItemTxns, read; subs, write
sa-attribute-rolebedrock:InvokeModel, secretsmanager:GetSecretValueOne model arn; the Sheets credential only
sa-ask-roleses:SendEmail, dynamodb:UpdateItemOne verified identity; subs table

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

PK   fingerprint       S   sha256(date|amount|merchant_raw|card)
     date              S   2026-07-02
     amount            N   14.40
     currency          S   GBP
     orig_amount       N   17.99
     orig_ccy          S   USD
     merchant_raw      S   SP * PROJTOOL
     merchant          S   projtool
     card              S   ****4417
     ttl               N   epoch, +7 years

GSI  merchant-index      PK merchant, SK date   — the grouping query

Table: subs

PK   sub_id            S   projtool|14.40|monthly
     merchant          S   projtool
     product           S   ProjTool (project management)
     interval          S   monthly | quarterly | annual
     amount            N   14.40
     annual            N   172.80
     first_seen        S   2024-09-14
     next_expected     S   2026-08-02
     owner             S   or null, which is the finding
     purpose           S   free text, from the owner
     last_answer       S   keep | drop | unknown
     last_asked        S   2025-07-05

GSI  ask-index           PK owner, SK next_expected   — the renewal sweep

Table: merchants

PK   merchant          S   projtool
     product           S   ProjTool (project management)
     confidence        N   0.92
     resolved_by       S   model | human | builtin
     resolved_at       S   2026-07-13T09:00:00Z

Every resolution is cached here forever. The model is only ever called
for a cleaned merchant string that has no row in this table.

Inbound and outbound

  • CSV drops go to an S3 prefix. Emailed statements arrive through an SES receipt rule writing to the same prefix. Both fire the same ingest.
  • Open Banking, where used, is granted the transactions scope only. There is no payment scope, no standing order scope, and no way to add one without a new consent flow.
  • Answer links in a renewal question are signed, scoped to one subscription, single-use, and expire after forty-five days.
  • No credential to any audited service is stored anywhere. There is no secret for ProjTool because the system has no reason to log in to ProjTool.

The model call

  • Model: anthropic.claude-haiku-4-5-20251001-v1:0 on Bedrock, used only to turn a card-statement descriptor into a likely product name.
  • Called once per unseen merchant, ever. A row in the merchants table means the model is never asked again about that string.
  • Output is a JSON schema with a product name, a category and a confidence, all nullable. A null product produces a question quoting the raw descriptor, which a human often recognises instantly.
  • Grounded with the merchants this business has already resolved, so variations of a known string resolve consistently.
  • Nothing about grouping touches a model. Finding a subscription is date arithmetic, and date arithmetic should be code.

Things worth knowing before you build it

  • Group on the original currency amount where the feed carries it, or every foreign-currency subscription splits into a dozen groups as the exchange rate moves.
  • Two charges is a coincidence and three is a subscription — except for annual, where waiting for a third means waiting two years. Treat two annual charges as probable and say so in the message.
  • Ask once a year, not once a interval. This is the setting people change first and regret, because a monthly email about eleven subscriptions is filtered within six weeks.
  • Write resolved owners back to the sheet. If this system is switched off, the useful output should survive in a spreadsheet.
  • Never add a write scope. The first feature request will be automatic cancellation, and granting it turns a small tool into serious security surface for a saving a human can realise in ninety seconds.

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

All posts