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
- 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
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 |
|---|---|---|---|
sa-ingest | S3 ObjectCreated + SES inbound | Parses the feed, fingerprints each line, writes txns | 60s / 1024 MB |
sa-group | EventBridge daily | Rebuilds subscription groups from the txn history | 120s / 1024 MB |
sa-attribute | SQS merchant queue | Owner lookup, cardholder fallback, one Bedrock call on new merchants | 20s / 512 MB |
sa-ask | EventBridge daily + Function URL | Renewal questions, the monthly digest, and the signed answer links | 30s / 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 |
|---|---|---|
sa-ingest-role | s3:GetObject, dynamodb:PutItem | The feeds prefix; the txns table only |
sa-group-role | dynamodb:Query/PutItem | Txns, read; subs, write |
sa-attribute-role | bedrock:InvokeModel, secretsmanager:GetSecretValue | One model arn; the Sheets credential only |
sa-ask-role | ses:SendEmail, dynamodb:UpdateItem | One 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:0on 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