
The Leak Nobody Pays For: Building AquaLedger, a Serverless Water Ledger for Apartment Societies
How I built AquaLedger, a serverless water ledger for apartment societies on AWS. It catches leaks overnight, bills each flat for the water it actually used, and plans tankers, all inside the free tier.
It's 3 a.m. in a 40-flat apartment society in Pune. Everyone is asleep, but in flat B-202 a cistern valve hasn't seated properly, and water is trickling into the bowl at 20 litres an hour. Nobody hears it. Nobody will notice for weeks.
By May the sump runs dry, and the committee calls a tanker: 10,000 litres for ₹1,800–2,200. Then another, and another. At the end of the month the bill is split equally across all 40 flats. The retired couple in A-101, who use barely 200 litres a day, pay exactly the same as the family of six upstairs, and the same as B-202, whose leak just cost everyone about ₹2,700.
I built AquaLedger so that this story ends differently.
Live app: https://main.d200aasztxvlg1.amplifyapp.com Source code: https://github.com/Utkarsh2730/AquaLedger
The problem: three water sources, one spreadsheet, zero visibility
Indian housing societies buy water from three places:
Source | Typical cost | The catch |
|---|---|---|
Municipal supply | ~₹22 per kL | Rationed: it never covers summer demand |
Borewell | ~₹9 per kL | Yield drops sharply in summer |
Private tankers | ~₹180+ per kL | Fills the gap, at about 8× the municipal price |
That last row changes how you should think about a leak. Every litre a leak wastes is effectively bought at the tanker price, because tankers are what fill the gap.
Most committees run all this from a notebook and a WhatsApp group, which leads to three recurring problems:
- Leaks hide for weeks. A running cistern wastes about 480 L a day, and nobody finds out until the bill arrives.
- Equal-split bills cause disputes. Small households subsidise heavy users and leaking flats. It's the most common argument at society meetings.
- Tankers are booked in a panic, because nobody knows how much water next month needs.
What AquaLedger does
AquaLedger is a web app for the society committee, plus a simple portal for residents.

- Catches leaks while everyone sleeps. A nightly scan checks every flat for unusual daily usage and for water running between 2 and 4 a.m. Smart meters can raise an alert within seconds.
- Bills every flat fairly. Each flat pays its share of metered litres plus a fixed common-area charge, in one click, next to what it would have paid under the old equal split. In the sample society, 19 of 40 flats pay less.
- Plans tankers ahead. A seasonal forecast gives next month's demand, the number of tankers to book and the budget.
- Answers plain-English questions such as "Why did our bill go up this month?", worked out from the society's own numbers.
- Gives residents a window, not a login. Every flat gets an access code by WhatsApp, email or a printed slip. Residents see only their own flat.
The architecture
I had two non-negotiables: it must cost almost nothing when idle, because a 40-flat society won't pay for servers running at 4 a.m., and one society must never see another's data. That pointed straight at a fully serverless design on AWS, defined end to end with AWS Amplify Gen 2.

Layer | AWS service | Role |
|---|---|---|
Hosting | AWS Amplify Hosting | CI/CD on every push; serves the static Next.js export from a CDN |
Identity | Amazon Cognito | Committee sign-up with email verification; one group per society; guest identity for residents |
API | AWS AppSync | GraphQL with group-based authorization and custom Lambda resolvers |
Data | Amazon DynamoDB | 8 tables, pay-per-request, with per-society secondary indexes |
Compute | AWS Lambda (ARM, Node 22) | admin-ops, query-api, analysis and meter-ingest |
Meter endpoint | Lambda Function URL | Public HTTPS endpoint for smart meters |
Scheduling | Amazon EventBridge Scheduler | Nightly leak scan at 01:15 IST |
Alerts | Amazon SNS | One topic; a filter policy routes each alert to its own committee |
Logs | Amazon CloudWatch Logs | JSON logs with one-month retention |
Optional | Amazon SES, AWS End User Messaging Social, AWS Budgets | Resident logins by email or WhatsApp; a spending alarm |
The rest of this post walks through the parts I found most interesting.
1. Multi-tenancy in one line: Cognito groups as tenants
Each society is a Cognito group whose name is the society's ID, and every record carries a
societyId . Amplify Gen 2's groupDefinedIn rule then does the hard part. AppSync only lets a user read or write a record if they belong to the group named in that record's societyId field.Plain text
Society: a .model({ societyId: a.id().required(), name: a.string().required(), deviceToken: a.string(), adminEmails: a.string().array(), lastNightlyScanAt: a.datetime(), // ... }) .identifier(["societyId"]) .authorization((allow) => [allow.groupDefinedIn("societyId")]),
When a committee member creates a society, the
admin-ops Lambda creates the Cognito group and adds them to it. Inviting a treasurer means adding them to the same group. Isolation is enforced by AppSync on every request, not by if statements I might forget.2. Catching leaks while everyone sleeps
The leak logic lives in plain TypeScript (
lib/core/leak.ts ) with no AWS dependencies. The Lambdas and the UI share it, and unit tests cover it. Two rules run every night.Spike rule. It compares yesterday's usage with the flat's normal, defined as a 30-day mean that ends a week earlier. The one-week gap matters: without it, a leak that started five days ago slowly becomes part of its own baseline and stops being flagged.
Plain text
const m = mean(baseline);const sd = Math.max(stdDev(baseline), m * 0.05);const score = (today - m) / sd;if (score > 2.5 && today > m * 1.3) { // flags e.g. "B-202 used 694 L on 9 Oct, 3.4× its normal 203 L a day."}
Night-flow rule. It flags steady flow in every hour between 2 and 4 a.m. on 3 or more of the last 7 nights. Real households flush occasionally at night; they don't run water continuously. A cistern does. In the sample society it produces: "B-202 shows steady flow of ~20 L/h between 2 and 4 a.m. on 5 of the last 7 nights: likely a running tap or cistern leak (~481 L/day wasted)."
Scheduling it took one line in the function definition, and EventBridge Scheduler does the rest:
Plain text
export const analysis = defineFunction({ name: "analysis", entry: "./handler.ts", timeoutSeconds: 120, memoryMB: 512, schedule: "45 19 * * ? *", // 19:45 UTC = 01:15 IST, every night ...LAMBDA_DEFAULTS, // arm64, Node 22, JSON logs, 1-month retention});
On the first of each month, the same job also bills the previous month and writes a monthly summary for the committee.
3. Live smart-meter alerts with a Lambda Function URL
For smart meters I didn't want to set up API Gateway, certificates or IoT policies for a hackathon. A Lambda Function URL gives a public HTTPS endpoint in a few lines of CDK. The function is defensive because it's public:
Plain text
// public endpoint: reject anything that can't be a meter reading before touching the databaseif ((event.body?.length ?? 0) > 4096) return json(413, { error: "Request too large" });// no household tap or burst pipe delivers more than ~200 L/min through a domestic meterif (Number(litres) / periodMinutes > 200) return json(400, { error: "Implausible flow rate" });const society = await getSociety(String(societyId));const token = event.headers["x-device-token"] ?? "";if (!society?.deviceToken || !safeEqual(token, society.deviceToken)) return json(401, { error: "Bad device token" });
A valid reading is stored, compared with the flat's 7-day average flow rate, and, if it is far above normal, it raises a
live_spike alert. That's de-duplicated to one per flat per 30 minutes, and it emails the committee. In the demo, you open a "tap" on a test meter and the alert appears within seconds.The committee can test the whole chain from the Live meters page, which sends readings from the browser through the same endpoint. That page also shows the endpoint URL, the society ID and a masked device token that can be rotated from Settings.
4. Alerts that reach only the right committee: SNS filter policies
Instead of a topic per society, there is one SNS topic. Each committee member's email subscription carries a filter policy on
societyId :Plain text
await sns.send( new SubscribeCommand({ TopicArn: topicArn(), Protocol: "email", Endpoint: email, Attributes: { FilterPolicy: JSON.stringify({ societyId: [societyId] }) }, }));
Publishing a leak alert with the
societyId message attribute delivers it only to that society's committee. That means no per-tenant infrastructure, and SNS email is in the always-free tier.5. Fair bills, explained
Billing is deliberately simple, so a resident can check it by hand:
flat's bill = (flat's litres ÷ all metered litres) × society's total water cost + common-area charge
Each bill shows the flat's share, the usage charge, the common charge and what the flat would have paid under the equal split. Generating bills for all 40 flats is one click. Re-running never touches bills already issued or marked paid.
6. Residents without accounts
Asking 40 households to create accounts and remember passwords is a non-starter. Instead, the committee clicks Give access to all flats. Every flat gets a random access code, sent by WhatsApp (one tap from the committee's own phone, free) or email, or printed as slips for letterboxes.
Only a hash of the code is stored:
Plain text
export async function hashAccessCode(input: string): Promise<string> { const data = new TextEncoder().encode(`aqualedger-flat-access:${normalizeAccessCode(input)}`); const digest = await globalThis.crypto.subtle.digest("SHA-256", data); return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");}
The resident portal calls a
residentPortal(code) query through a Cognito guest identity. The Lambda finds the flat by hash and returns only that flat's view. Issuing a new code switches off the old one immediately.7. A System check that explains itself
Serverless apps have many moving parts that can be silently misconfigured: an unconfirmed SNS subscription, a schedule that never ran, a meter endpoint with the wrong permissions. Settings → System check tests each one live and says how to fix it. For example:
- Alert emails: "Waiting for confirmation: treasurer@… → open the 'AWS Notification - Subscription Confirmation' email."
- Nightly leak scan: the job writes
lastNightlyScanAton each run, which proves the EventBridge schedule actually fires.
- Smart-meter endpoint: the check POSTs a reading with a deliberately wrong token and expects a 401.
What it costs
At society scale, the whole stack sits inside the AWS free tier: Lambda's 1M requests a month, DynamoDB on-demand, SNS email, EventBridge Scheduler and Amplify Hosting. Nothing is always on. Lambdas run on ARM/Graviton (about 20% cheaper per GB-second), logs expire after a month, and an optional AWS Budgets alarm emails you at 80% and 100% of a limit you set.
That's what makes the business model work. AquaLedger is priced at ₹10–15 per flat per month, so a 40-flat society pays about ₹480 a month. One caught leak like B-202's (481 L a day, about ₹2,700 a month at tanker prices) pays for more than five months.
What went wrong (and what I learned)
- Amplify treated my static site as server-rendered. The app is a static Next.js export, but it was created as a compute (SSR) app, so every page returned 404. The fix was
aws amplify update-app --platform WEB. Check the platform first if a static Amplify build "succeeds" but serves nothing.
-
npm cifailed in the Amplify build image with lockfile errors I couldn't reproduce locally. They were caused by bundled dependencies. Switching the build tonpm install --prefer-offlinefixed it.
- Table permissions didn't cover the indexes. Amplify exposes model tables as imported tables, so
grantReadWriteDatadoesn't include secondary indexes. I addeddynamodb:Queryontable/index/*explicitly.
- Reserved concurrency broke deploys on a new account. New accounts have a 10-execution limit, and AWS rejects any reservation that leaves fewer than 10 unreserved. I removed it and validate input before any database write instead.
- I dropped generative AI and the product got better. I originally planned to have Amazon Bedrock write the monthly summaries and chat answers, but model access wasn't available on my account. I replaced it with rule-based summaries and keyword answers computed from the dashboard numbers. They're free, instant and never fail.
- Weekends caused false leak alarms. Testing on 11 October, the sample data flagged two healthy flats: people use about 10% more water on weekends, and the September-to-October seasonal step added a little more. Requiring a spike to be 30% above normal (as well as 2.5 standard deviations) removed every false alarm across six weeks of dates, and the real leak is still caught.
What's next
- Pilot with two or three real societies in Pune.
- Integrations with smart-meter vendors and building-management gateways.
- Automatic WhatsApp and email logins at scale (WhatsApp Business through AWS End User Messaging Social, and SES production access).
- Online bill payment, so the ledger closes the loop.
Try it
- Live app: https://main.d200aasztxvlg1.amplifyapp.com . Choose Committee login → Create Account, then tick Start with 6 months of sample data when you set up the society. Then go to Leak alerts → Scan for leaks now and watch flat B-202 get caught.
- Code: https://github.com/Utkarsh2730/AquaLedger .
npm install && npm run demoruns the whole app offline in your browser, with no AWS account needed.
Water is the one utility where a society pays the highest price for its own waste. AquaLedger makes that waste visible the morning after it starts, not at the end of the month.
Built by Utkarsh Sonthalia with AWS Amplify Gen 2, Amazon Cognito, AWS AppSync, Amazon DynamoDB, AWS Lambda, Amazon EventBridge Scheduler and Amazon SNS.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article