Build a Debt Simplification API on AWS

Deploy a Splitwise-style serverless API that minimizes debt transactions.

Introduction

30 Second Summary

Splitting bills with friends always gets messy. Figuring out who owes what with the fewest transfers possible is a surprisingly tricky math problem.

In this project, you will build a serverless REST API on AWS that solves this problem. Your API takes a group of people with shared expenses and returns the minimum number of transactions needed to settle all debts.

What You'll Build

A Splitwise-style backend API powered by AWS Lambda, API Gateway, and DynamoDB that creates groups, records expenses, and computes optimized debt settlements using the greedy min-cash-flow algorithm.

By the end of this project, you'll have:

  • A SAM template defining all your AWS infrastructure as code, including Lambda functions, an API Gateway, and DynamoDB tables.
  • Three Lambda functions handling group management, expense tracking, and debt settlement.
  • A working greedy min-cash-flow algorithm that minimizes the number of transactions between group members.
  • Secret Mission: Add a visualization endpoint that shows the step-by-step execution of the settlement algorithm.

Are there any prerequisites?

You need an AWS account with the AWS CLI configured, Node.js installed, and VS Code installed. You do not need prior experience with serverless or DynamoDB.

Before We Start

To build our debt simplification API, we need a way to define cloud infrastructure as code and deploy it to AWS.

The AWS SAM CLI handles exactly this. It lets you define Lambda functions, DynamoDB tables, and API Gateway endpoints in a single template file, then deploy them all with one command. In this step, you'll install SAM CLI, scaffold the project folder structure, and confirm your AWS credentials are working.

In this step, get ready to:
  • Install the AWS SAM CLI.
  • Create the project directory and file structure.
  • Verify your AWS credentials.
Install AWS SAM CLI

The SAM CLI is the tool that packages and deploys your serverless application to AWS. Let's check whether you already have it installed.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check if SAM CLI is installed by running:
sam --version

✔️ I see version 1.162.1 or higher

You already have the correct version of SAM CLI installed. Skip ahead to the next substep.

ⓧ I see an older version

You have SAM CLI installed but it needs to be upgraded. Follow the instructions for your operating system below.

macOS

  • Upgrade SAM CLI by running:
brew upgrade aws-sam-cli
  • Verify the upgrade by running sam --version again. You should see 1.162.1 or later.

Windows

  • Download the latest MSI installer from the AWS SAM CLI releases page. The file is named AWS_SAM_CLI_64_PY3.msi.
  • Run the MSI installer and follow the prompts to upgrade.
  • Close your current PowerShell window and open a new one.
  • Run sam --version again. You should see 1.162.1 or later.

ⓧ Command not found

SAM CLI is not installed yet. Follow the instructions for your operating system below.

macOS

  • Install SAM CLI using Homebrew by running:
brew install aws-sam-cli
  • Verify the installation by running:
sam --version
  • You should see SAM CLI, version 1.162.1 or later in the output.
Homebrew not found?

If you see a "command not found: brew" error, you need to install Homebrew first. Visit brew.sh and run the install script shown on the page, then try the SAM CLI install command again.

Help me install Homebrew on my Mac.

Windows

  • Download the MSI installer from the AWS SAM CLI releases page. The file is named AWS_SAM_CLI_64_PY3.msi.
  • Run the downloaded MSI file and follow the on-screen prompts to complete the installation.
  • Close your current PowerShell window and open a new one.
  • Verify the installation by running:
sam --version
  • You should see SAM CLI, version 1.162.1 or later in the output.
  • Next, enable Windows long paths so SAM can work with deep file paths. Right-click the PowerShell icon and select Run as administrator.
  • In the elevated PowerShell window, run:
New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force
  • Restart your computer for the change to take effect.
Access denied when setting long paths?

Make sure you opened PowerShell as an administrator. Right-click the PowerShell icon and select Run as administrator before running the command.

Help me run PowerShell as administrator.

  • Confirm your AWS CLI is working by running:
aws --version
  • You should see output starting with aws-cli/2. followed by a version number.
  • Also confirm Node.js is available by running:
node --version
  • You should see v22 or later.

AWS CLI or Node.js not found?

If aws --version fails, follow the AWS CLI installation guide to install it. If node --version fails, download Node.js from nodejs.org.

Help me troubleshoot my AWS CLI or Node.js installation.

Create the project structure

Instead of using sam init (which generates boilerplate we don't need), you'll create the project files manually. This gives you full visibility into what each file does.

Why build from scratch instead of using sam init?

The sam init command scaffolds a sample app with test files, README, events, and a .gitignore. Most of these are unnecessary for learning what each component does.

By creating files manually, you'll understand exactly what a SAM project needs to work. Nothing hidden, nothing extra.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and its subdirectories by running these commands:
mkdir splitwise-api
cd splitwise-api
mkdir -p src/groups src/expenses src/settle
  • Create the empty files for your Lambda handlers and SAM template by running:

macOS

touch template.yaml src/groups/index.mjs src/expenses/index.mjs src/settle/index.mjs

Windows

New-Item template.yaml, src/groups/index.mjs, src/expenses/index.mjs, src/settle/index.mjs -Force
  • Confirm the files were created by running:
ls
  • You should see src and template.yaml in the output.

What are these files for?

The template.yaml file is where you define all your AWS infrastructure (tables, functions, and API routes). Each .mjs file will hold the handler code for one Lambda function.

The three handlers map to different responsibilities: groups manages user groups, expenses records who paid what, and settle computes the optimized debt settlements.

Don't see the files?

Make sure you ran the mkdir -p command before attempting to create the files. The -p flag creates parent directories that don't exist yet.

Help me create the project directory structure.

Verify AWS credentials

SAM needs access to your AWS account to deploy resources. Let's confirm your credentials are configured correctly.

  • Verify your AWS identity by running:
aws sts get-caller-identity
  • You should see a JSON response containing your Account, UserId, and Arn fields.

What does this command do?

The aws sts get-caller-identity command asks AWS "who am I?" using your stored credentials. If it returns your account details, SAM will be able to deploy resources on your behalf.

Seeing a credentials error?

If you see an error about credentials not being found, run aws configure and enter your Access Key ID, Secret Access Key, and preferred region. You can find your credentials in the AWS Console under IAM > Users > your user > Security credentials.

Help me configure my AWS CLI credentials.

Your environment is ready. All three tools are confirmed working, and your project skeleton is in place. Next up, you'll fill in the template.yaml file to define your DynamoDB tables, Lambda functions, and API routes.

Define the Infrastructure with SAM

Your project folder is ready, but AWS has no idea what you want to build yet. Right now, the template.yaml file is empty.

A SAM template is a YAML file that declares every resource your application needs. In this step, you will write the full template that defines your database tables, Lambda functions, API routes, and their connections.

In this step, get ready to:
  • Define two DynamoDB tables with efficient key schemas.
  • Wire up three Lambda functions to API Gateway routes.
  • Output the live API URL for easy access after deployment.
Define DynamoDB tables

Every serverless app starts with its data layer. You need two DynamoDB tables: one to store groups and one to store expenses.

  • Open template.yaml in VS Code and paste the following template skeleton with both DynamoDB table definitions:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Splitwise-style debt simplification API

Globals:
  Function:
    Runtime: nodejs22.x
    Timeout: 30
    MemorySize: 128
    Environment:
      Variables:
        GROUPS_TABLE: !Ref GroupsTable
        EXPENSES_TABLE: !Ref ExpensesTable

Resources:
  SplitApi:
    Type: AWS::Serverless::HttpApi

  GroupsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "${AWS::StackName}-groups"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: groupId
          AttributeType: S
      KeySchema:
        - AttributeName: groupId
          KeyType: HASH

  ExpensesTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "${AWS::StackName}-expenses"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: groupId
          AttributeType: S
        - AttributeName: expenseId
          AttributeType: S
      KeySchema:
        - AttributeName: groupId
          KeyType: HASH
        - AttributeName: expenseId
          KeyType: RANGE

What does this code do?

The top section sets the SAM transform and declares global defaults that apply to every Lambda function (runtime, timeout, memory, and environment variables).

  • SplitApi creates an HTTP API Gateway that routes incoming requests to your functions.
  • GroupsTable uses a single partition key (groupId) so each group is one fast lookup.
  • ExpensesTable uses a composite key: groupId as the partition key and expenseId as the sort key. This lets you query ALL expenses for a group in one efficient request.
  • PAY_PER_REQUEST billing means you only pay for actual reads and writes. No capacity planning needed.
  • Save template.yaml.
  • Check that VS Code does not show any red or yellow squiggly lines in the file. Valid YAML should have clean syntax highlighting with no warnings.

Seeing red underlines in your YAML?

  • YAML is whitespace-sensitive. Make sure you are using spaces (not tabs) for indentation. Each nested level uses exactly 2 spaces.
  • Check that the !Sub and !Ref intrinsic functions have a space after the exclamation mark.

Help me fix YAML indentation errors in my SAM template.

Define Lambda functions and API routes

With the data layer in place, you need three Lambda functions to handle incoming API requests: one for groups, one for expenses, and one for computing settlements.

  • In template.yaml, find the line that says GroupsTable:.
  • Add the following three Lambda function definitions directly ABOVE GroupsTable:, at the same indentation level (2 spaces in from Resources:):
  GroupsFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/groups/
      Handler: index.handler
      Events:
        CreateGroup:
          Type: HttpApi
          Properties:
            ApiId: !Ref SplitApi
            Path: /groups
            Method: POST
        GetGroup:
          Type: HttpApi
          Properties:
            ApiId: !Ref SplitApi
            Path: /groups/{groupId}
            Method: GET
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref GroupsTable

  ExpensesFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/expenses/
      Handler: index.handler
      Events:
        AddExpense:
          Type: HttpApi
          Properties:
            ApiId: !Ref SplitApi
            Path: /groups/{groupId}/expenses
            Method: POST
        GetExpenses:
          Type: HttpApi
          Properties:
            ApiId: !Ref SplitApi
            Path: /groups/{groupId}/expenses
            Method: GET
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ExpensesTable

  SettleFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/settle/
      Handler: index.handler
      Events:
        Settle:
          Type: HttpApi
          Properties:
            ApiId: !Ref SplitApi
            Path: /groups/{groupId}/settle
            Method: GET
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ExpensesTable

What does this code do?

  • GroupsFunction handles POST /groups (create a group) and GET /groups/{groupId} (retrieve a group).
  • ExpensesFunction handles POST /groups/{groupId}/expenses (add an expense) and GET /groups/{groupId}/expenses (list expenses).
  • SettleFunction handles GET /groups/{groupId}/settle (compute optimized debt settlements).
  • Each function gets a DynamoDBCrudPolicy scoped to only the table it needs. This follows the principle of least privilege.
  • The CodeUri tells SAM where to find the function's source code. The Handler points to the exported handler function in index.mjs.

Why does SAM handle IAM roles automatically?

In raw CloudFormation, you would need to write 20+ lines of IAM role and policy definitions for each function. SAM's DynamoDBCrudPolicy shorthand generates all of that for you, scoped to exactly the table you specify.

  • Save template.yaml.
  • Confirm the three function blocks sit between SplitApi: and GroupsTable: in the Resources section. The indentation of each function name should align with SplitApi: and GroupsTable:.

YAML looks misaligned or VS Code shows errors?

  • Each function name (GroupsFunction:, ExpensesFunction:, SettleFunction:) must be indented exactly 2 spaces from the left margin, at the same level as SplitApi:.
  • Make sure there are no extra blank lines between the end of SettleFunction and the start of GroupsTable:. One blank line is fine.

Help me debug my SAM template YAML structure.

Define outputs

After deployment, you need to know the URL of your live API. The Outputs section tells CloudFormation to print the API endpoint when the deployment finishes.

  • At the very bottom of template.yaml (after the ExpensesTable resource definition), add the following Outputs section:
Outputs:
  ApiUrl:
    Description: API Gateway endpoint URL
    Value: !Sub "https://${SplitApi}.execute-api.${AWS::Region}.amazonaws.com"

What does this code do?

The !Sub function substitutes the API ID and AWS region into the URL string at deploy time. After deployment, this URL will be printed to your terminal so you can immediately start calling your API.

  • Save template.yaml.
  • Validate your template by running this command from inside the splitwise-api folder:
sam validate
  • You should see a message confirming the template is valid.

sam validate showing an error?

  • Make sure you are running the command from inside the splitwise-api directory where template.yaml lives.
  • Check that Outputs: is at the root level of the file (no leading spaces), NOT nested inside Resources:.
  • Confirm there are no duplicate keys. Each resource name must appear only once under Resources:.

Help me fix my SAM template validation error.

✔️ Awesome, I've got everything!

Great. Double check that you have saved template.yaml and that sam validate passes without errors.

ⓧ I'd like to double check the full code

Here is the complete template.yaml file. Make sure yours matches exactly:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Splitwise-style debt simplification API

Globals:
  Function:
    Runtime: nodejs22.x
    Timeout: 30
    MemorySize: 128
    Environment:
      Variables:
        GROUPS_TABLE: !Ref GroupsTable
        EXPENSES_TABLE: !Ref ExpensesTable

Resources:
  SplitApi:
    Type: AWS::Serverless::HttpApi

  GroupsFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/groups/
      Handler: index.handler
      Events:
        CreateGroup:
          Type: HttpApi
          Properties:
            ApiId: !Ref SplitApi
            Path: /groups
            Method: POST
        GetGroup:
          Type: HttpApi
          Properties:
            ApiId: !Ref SplitApi
            Path: /groups/{groupId}
            Method: GET
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref GroupsTable

  ExpensesFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/expenses/
      Handler: index.handler
      Events:
        AddExpense:
          Type: HttpApi
          Properties:
            ApiId: !Ref SplitApi
            Path: /groups/{groupId}/expenses
            Method: POST
        GetExpenses:
          Type: HttpApi
          Properties:
            ApiId: !Ref SplitApi
            Path: /groups/{groupId}/expenses
            Method: GET
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ExpensesTable

  SettleFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/settle/
      Handler: index.handler
      Events:
        Settle:
          Type: HttpApi
          Properties:
            ApiId: !Ref SplitApi
            Path: /groups/{groupId}/settle
            Method: GET
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ExpensesTable

  GroupsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "${AWS::StackName}-groups"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: groupId
          AttributeType: S
      KeySchema:
        - AttributeName: groupId
          KeyType: HASH

  ExpensesTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "${AWS::StackName}-expenses"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: groupId
          AttributeType: S
        - AttributeName: expenseId
          AttributeType: S
      KeySchema:
        - AttributeName: groupId
          KeyType: HASH
        - AttributeName: expenseId
          KeyType: RANGE

Outputs:
  ApiUrl:
    Description: API Gateway endpoint URL
    Value: !Sub "https://${SplitApi}.execute-api.${AWS::Region}.amazonaws.com"

Your entire infrastructure is now defined in a single file. Next up, you will write the Lambda handler code that actually processes API requests and talks to DynamoDB.

Build the Group and Expense Endpoints

Your infrastructure blueprint is complete. The SAM template defines exactly which AWS resources to create, but the Lambda functions themselves are still empty shells.

In this step, you will write the actual code that runs inside those Lambda functions. Each handler reads HTTP requests, talks to DynamoDB, and returns JSON responses.

In this step, get ready to:
  • Build the Groups handler that creates and retrieves groups.
  • Build the Expenses handler that records and lists expenses.
  • Review the HTTP API v2 event format your handlers use.
Build the Groups handler

The Groups handler lives in src/groups/index.mjs. It handles two routes: creating a new group (POST) and retrieving an existing group (GET).

Why no npm install?

The AWS SDK v3 is bundled in the Lambda Node.js runtime. Your code can import @aws-sdk/client-dynamodb and @aws-sdk/lib-dynamodb without installing anything locally.

This means no package.json or node_modules folder needed for this project.

  • Open src/groups/index.mjs in VS Code and add the imports, client setup, and handler routing by pasting this code:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand, GetCommand } from "@aws-sdk/lib-dynamodb";
import { randomUUID } from "node:crypto";

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.GROUPS_TABLE;

export const handler = async (event) => {
  const method = event.requestContext.http.method;

  if (method === "POST") {
    return createGroup(event);
  } else if (method === "GET") {
    return getGroup(event);
  }

  return { statusCode: 405, body: JSON.stringify({ message: "Method not allowed" }) };
};

What does this code do?

  • DynamoDBDocumentClient is a higher-level wrapper around the raw DynamoDB client. It automatically converts JavaScript objects to DynamoDB's attribute format and back.
  • TABLE_NAME reads from the GROUPS_TABLE environment variable. SAM injects this at deploy time based on your template.yaml globals.
  • The handler reads event.requestContext.http.method to route POST and GET requests to separate functions.
  • Below the handler, add the createGroup function that generates a UUID, builds the group item, and stores it in DynamoDB:
async function createGroup(event) {
  const body = JSON.parse(event.body);
  const groupId = randomUUID();

  const item = {
    groupId,
    name: body.name,
    members: body.members,
    createdAt: new Date().toISOString(),
  };

  await docClient.send(new PutCommand({
    TableName: TABLE_NAME,
    Item: item,
  }));

  return {
    statusCode: 201,
    body: JSON.stringify(item),
  };
}

What does this code do?

  • randomUUID() from Node's built-in crypto module generates a unique ID for each group.
  • PutCommand writes the full item to the GroupsTable. DynamoDB stores it with groupId as the partition key.
  • The function returns a 201 status code ("created") with the full group object so the caller knows the assigned ID.
  • Below createGroup, add the getGroup function that retrieves a group by its ID:
async function getGroup(event) {
  const groupId = event.pathParameters.groupId;

  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { groupId },
  }));

  if (!result.Item) {
    return { statusCode: 404, body: JSON.stringify({ message: "Group not found" }) };
  }

  return {
    statusCode: 200,
    body: JSON.stringify(result.Item),
  };
}

What does this code do?

  • event.pathParameters.groupId extracts the ID from the URL path (e.g. /groups/abc-123 gives abc-123).
  • GetCommand fetches a single item by its primary key. If the group does not exist, result.Item is undefined and the handler returns a 404.
  • Save src/groups/index.mjs.
  • Check that VS Code does not show any red underlines in the file. The function names createGroup and getGroup should have no squiggly warnings.

Seeing red underlines?

  • Make sure your file extension is .mjs (not .js). The import syntax requires ES module format.
  • Check that createGroup and getGroup are spelled exactly the same in the handler's return statements and in the function definitions below.
  • help me fix a syntax error in my groups handler

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/groups/index.mjs before moving on.

ⓧ I'd like to double check the full code

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand, GetCommand } from "@aws-sdk/lib-dynamodb";
import { randomUUID } from "node:crypto";

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.GROUPS_TABLE;

export const handler = async (event) => {
  const method = event.requestContext.http.method;

  if (method === "POST") {
    return createGroup(event);
  } else if (method === "GET") {
    return getGroup(event);
  }

  return { statusCode: 405, body: JSON.stringify({ message: "Method not allowed" }) };
};

async function createGroup(event) {
  const body = JSON.parse(event.body);
  const groupId = randomUUID();

  const item = {
    groupId,
    name: body.name,
    members: body.members,
    createdAt: new Date().toISOString(),
  };

  await docClient.send(new PutCommand({
    TableName: TABLE_NAME,
    Item: item,
  }));

  return {
    statusCode: 201,
    body: JSON.stringify(item),
  };
}

async function getGroup(event) {
  const groupId = event.pathParameters.groupId;

  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { groupId },
  }));

  if (!result.Item) {
    return { statusCode: 404, body: JSON.stringify({ message: "Group not found" }) };
  }

  return {
    statusCode: 200,
    body: JSON.stringify(result.Item),
  };
}
Build the Expenses handler

The Expenses handler follows the same pattern as the Groups handler. The key difference is that it uses QueryCommand instead of GetCommand to fetch all expenses for a group in one efficient request.

  • Open src/expenses/index.mjs in VS Code and add the imports, client setup, and handler routing by pasting this code:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand, QueryCommand } from "@aws-sdk/lib-dynamodb";
import { randomUUID } from "node:crypto";

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.EXPENSES_TABLE;

export const handler = async (event) => {
  const method = event.requestContext.http.method;

  if (method === "POST") {
    return addExpense(event);
  } else if (method === "GET") {
    return getExpenses(event);
  }

  return { statusCode: 405, body: JSON.stringify({ message: "Method not allowed" }) };
};

What changed from the Groups handler?

  • The import swaps GetCommand for QueryCommand. A Query returns multiple items that share the same partition key, which is perfect for fetching all expenses in a group.
  • TABLE_NAME reads from EXPENSES_TABLE instead of GROUPS_TABLE.
  • Below the handler, add the addExpense function that records a new expense with its split information:
async function addExpense(event) {
  const groupId = event.pathParameters.groupId;
  const body = JSON.parse(event.body);
  const expenseId = randomUUID();

  const item = {
    groupId,
    expenseId,
    description: body.description,
    amount: body.amount,
    paidBy: body.paidBy,
    splits: body.splits,
    createdAt: new Date().toISOString(),
  };

  await docClient.send(new PutCommand({
    TableName: TABLE_NAME,
    Item: item,
  }));

  return {
    statusCode: 201,
    body: JSON.stringify(item),
  };
}

What does this code do?

  • The item stores both groupId and expenseId. Together they form the composite key you defined in template.yaml (partition key + sort key).
  • splits is an array of objects (e.g. {memberId, amount}) describing how the expense is divided. DynamoDB stores this as a nested list.
  • Below addExpense, add the getExpenses function that queries all expenses for a specific group:
async function getExpenses(event) {
  const groupId = event.pathParameters.groupId;

  const result = await docClient.send(new QueryCommand({
    TableName: TABLE_NAME,
    KeyConditionExpression: "groupId = :gid",
    ExpressionAttributeValues: { ":gid": groupId },
  }));

  return {
    statusCode: 200,
    body: JSON.stringify(result.Items),
  };
}

Why QueryCommand instead of GetCommand?

GetCommand fetches exactly one item by its full primary key. QueryCommand fetches all items that share the same partition key.

Since the ExpensesTable uses groupId as its partition key and expenseId as the sort key, a Query on groupId returns every expense in that group in a single request.

  • Save src/expenses/index.mjs.
  • Check that VS Code does not show any red underlines in the file.

Seeing an error on QueryCommand?

  • Make sure the import line includes QueryCommand (not GetCommand). This file needs PutCommand and QueryCommand.
  • Check that KeyConditionExpression uses a colon prefix on the placeholder (:gid) and matches the key in ExpressionAttributeValues.
  • help me fix my expenses handler

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/expenses/index.mjs before moving on.

ⓧ I'd like to double check the full code

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand, QueryCommand } from "@aws-sdk/lib-dynamodb";
import { randomUUID } from "node:crypto";

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.EXPENSES_TABLE;

export const handler = async (event) => {
  const method = event.requestContext.http.method;

  if (method === "POST") {
    return addExpense(event);
  } else if (method === "GET") {
    return getExpenses(event);
  }

  return { statusCode: 405, body: JSON.stringify({ message: "Method not allowed" }) };
};

async function addExpense(event) {
  const groupId = event.pathParameters.groupId;
  const body = JSON.parse(event.body);
  const expenseId = randomUUID();

  const item = {
    groupId,
    expenseId,
    description: body.description,
    amount: body.amount,
    paidBy: body.paidBy,
    splits: body.splits,
    createdAt: new Date().toISOString(),
  };

  await docClient.send(new PutCommand({
    TableName: TABLE_NAME,
    Item: item,
  }));

  return {
    statusCode: 201,
    body: JSON.stringify(item),
  };
}

async function getExpenses(event) {
  const groupId = event.pathParameters.groupId;

  const result = await docClient.send(new QueryCommand({
    TableName: TABLE_NAME,
    KeyConditionExpression: "groupId = :gid",
    ExpressionAttributeValues: { ":gid": groupId },
  }));

  return {
    statusCode: 200,
    body: JSON.stringify(result.Items),
  };
}
Review the request and response format

Both handlers parse HTTP API v2 events. This is the event format that SAM's AWS::Serverless::HttpApi sends to your Lambda functions.

What is the HTTP API v2 event format?

When a request hits your API Gateway, Lambda receives an event object. The key fields your handlers use are:

  • event.requestContext.http.method contains the HTTP method (POST, GET).
  • event.pathParameters.groupId contains the value from the {groupId} placeholder in the URL path.
  • event.body contains the raw JSON string from POST requests. Your handler calls JSON.parse(event.body) to read it.

This is different from the older REST API format where you would read event.httpMethod directly. The HTTP API v2 nests it under requestContext.http.

Here is what a POST request to create a group looks like and what the handler returns:

{
  "name": "Weekend Trip",
  "members": ["Alice", "Bob", "Charlie"]
}
{
  "groupId": "a1b2c3d4-...",
  "name": "Weekend Trip",
  "members": ["Alice", "Bob", "Charlie"],
  "createdAt": "2026-06-13T10:00:00.000Z"
}

And here is what a POST request to add an expense looks like:

{
  "description": "Dinner",
  "amount": 30,
  "paidBy": "Alice",
  "splits": [
    { "memberId": "Alice", "amount": 10 },
    { "memberId": "Bob", "amount": 10 },
    { "memberId": "Charlie", "amount": 10 }
  ]
}

How do the status codes map?

  • 201 means "created". Both POST handlers return this on success.
  • 200 means "OK". Both GET handlers return this when the requested data exists.
  • 404 means "not found". The Groups GET handler returns this when a group ID does not match any record.
  • 405 means "method not allowed". Both handlers return this for any HTTP method other than POST or GET.

Both handlers are ready. Next up, you will write the settlement algorithm that reads these expenses and computes the minimum number of transactions to settle all debts.

Implement the Min-Cash-Flow Algorithm

Your group and expense endpoints are ready to store data. Now it's time to build the feature that makes this API valuable.

When a group of friends splits expenses, the naive approach is for every person to pay back every other person individually. That quickly becomes a mess. The min-cash-flow algorithm computes the minimum number of transactions needed to settle all debts.

In this step, get ready to:
  • Compute net balances from a group's expense records.
  • Implement the greedy settlement algorithm that minimizes transactions.
  • Wire both functions into a Lambda handler that returns simplified settlements.
Compute net balances

Before you can simplify debts, you need to know who owes what overall. A net balance takes all the expenses in a group and boils them down to a single number per person.

A positive balance means the person is owed money (they paid more than their share). A negative balance means they owe money (they paid less than their share).

  • Open src/settle/index.mjs in VS Code and replace the empty file with the following code:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.EXPENSES_TABLE;

function computeBalances(expenses) {
  const balances = {};

  expenses.forEach((expense) => {
    const { paidBy, amount, splits } = expense;
    // Credit the payer the full amount they covered
    balances[paidBy] = (balances[paidBy] || 0) + amount;
    // Debit each person by their share of the expense
    splits.forEach((split) => {
      balances[split.memberId] = (balances[split.memberId] || 0) - split.amount;
    });
  });

  return balances;
}

What does this code do?

The imports and setup create a DynamoDB Document Client, the same pattern you used in the groups and expenses handlers. The TABLE_NAME reads from the EXPENSES_TABLE environment variable defined in your SAM template.

The computeBalances function loops through every expense and adjusts each person's balance. If Alice paid $30 split among Alice, Bob, and Carol ($10 each), Alice gets +30 (she paid) and then -10 (her share). Bob gets -10. Carol gets -10. Alice's net balance ends up at +20, meaning she is owed $20 total.

You won't see a change yet. The next chunk adds the settlement algorithm that uses these balances.

Implement the greedy settlement algorithm

With net balances computed, you need to turn them into actual payment instructions. The greedy approach separates everyone into debtors (negative balance) and creditors (positive balance), then repeatedly pairs the largest debtor with the largest creditor.

This produces at most N-1 transactions for N people. The time complexity is O(n log n) due to the initial sort.

  • Below the computeBalances function, add the minimizeCashFlow function:
function minimizeCashFlow(balances) {
  const debtors = [];
  const creditors = [];

  // Separate members into people who owe and people who are owed
  Object.entries(balances).forEach(([person, balance]) => {
    if (balance < -0.01) {
      debtors.push({ person, amount: Math.abs(balance) });
    } else if (balance > 0.01) {
      creditors.push({ person, amount: balance });
    }
  });

  // Sort both arrays so the largest amounts come first
  debtors.sort((a, b) => b.amount - a.amount);
  creditors.sort((a, b) => b.amount - a.amount);

  const settlements = [];
  let i = 0;
  let j = 0;

  // Greedy pairing: match largest debtor with largest creditor
  while (i < debtors.length && j < creditors.length) {
    const transferAmount = Math.min(debtors[i].amount, creditors[j].amount);

    settlements.push({
      from: debtors[i].person,
      to: creditors[j].person,
      amount: Math.round(transferAmount * 100) / 100,
    });

    debtors[i].amount -= transferAmount;
    creditors[j].amount -= transferAmount;

    // Advance past anyone whose balance is now settled
    if (debtors[i].amount < 0.01) i++;
    if (creditors[j].amount < 0.01) j++;
  }

  return settlements;
}

How does the greedy algorithm work?

The algorithm uses a 0.01 threshold instead of strict zero checks. This avoids floating-point precision issues where a balance might be 0.0000000001 instead of exactly zero.

At each iteration, the algorithm transfers the smaller of the two amounts (debtor's debt vs creditor's credit). This fully settles at least one person per iteration. For example, if the biggest debtor owes $50 and the biggest creditor is owed $30, the transfer is $30. The creditor is settled, and the debtor still owes $20 to the next creditor.

The Math.round(... * 100) / 100 ensures amounts are rounded to two decimal places in the output.

You won't see a change yet. The next chunk adds the handler that calls both functions.

Wire into the Lambda handler

The handler is the entry point that AWS Lambda invokes. It queries all expenses for the group from DynamoDB, passes them to computeBalances, runs minimizeCashFlow, and returns both the balances and settlements in the response.

  • Find the line const TABLE_NAME = process.env.EXPENSES_TABLE; in your file.
  • Add the handler function directly below it, above the computeBalances function:
export const handler = async (event) => {
  const groupId = event.pathParameters.groupId;

  // Query all expenses for this group from DynamoDB
  const result = await docClient.send(new QueryCommand({
    TableName: TABLE_NAME,
    KeyConditionExpression: "groupId = :gid",
    ExpressionAttributeValues: { ":gid": groupId },
  }));

  const expenses = result.Items;

  // Return empty results if no expenses exist yet
  if (!expenses || expenses.length === 0) {
    return {
      statusCode: 200,
      body: JSON.stringify({ balances: {}, settlements: [] }),
    };
  }

  // Compute who owes what, then minimize the transactions
  const balances = computeBalances(expenses);
  const settlements = minimizeCashFlow(balances);

  return {
    statusCode: 200,
    body: JSON.stringify({ balances, settlements }),
  };
};

What does this handler do?

The handler reads groupId from the URL path parameters, just like your expenses handler does. It uses QueryCommand with KeyConditionExpression to efficiently retrieve all expenses for that group in a single DynamoDB call.

If no expenses exist, it returns empty balances and settlements. Otherwise, it chains computeBalances and minimizeCashFlow together and returns both results to the caller.

  • Save src/settle/index.mjs.
  • Verify the file has this structure from top to bottom: imports, client setup, handler, computeBalances, then minimizeCashFlow.

File structure looking wrong?

Make sure the handler function sits between the TABLE_NAME line and the computeBalances function. The handler calls both computeBalances and minimizeCashFlow, which are defined below it. JavaScript function declarations are hoisted, so this ordering works.

If you see red squiggly lines in VS Code, check that you have exactly one import block at the top and that there are no duplicate function names.

help me fix the structure of my settle handler file

Why does the algorithm work for the triangle case?

Consider three people: A owes B $10, and B owes C $10. The naive approach needs two transactions. But the net balances are: A is -10, B is 0, C is +10. The algorithm sees only one debtor (A) and one creditor (C), producing a single transaction: A pays C $10. The intermediary B is skipped entirely.

✔️ Awesome, I've got everything!

Great. Double check you've saved src/settle/index.mjs before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/settle/index.mjs file:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.EXPENSES_TABLE;

export const handler = async (event) => {
  const groupId = event.pathParameters.groupId;

  const result = await docClient.send(new QueryCommand({
    TableName: TABLE_NAME,
    KeyConditionExpression: "groupId = :gid",
    ExpressionAttributeValues: { ":gid": groupId },
  }));

  const expenses = result.Items;

  if (!expenses || expenses.length === 0) {
    return {
      statusCode: 200,
      body: JSON.stringify({ balances: {}, settlements: [] }),
    };
  }

  const balances = computeBalances(expenses);
  const settlements = minimizeCashFlow(balances);

  return {
    statusCode: 200,
    body: JSON.stringify({ balances, settlements }),
  };
};

function computeBalances(expenses) {
  const balances = {};

  expenses.forEach((expense) => {
    const { paidBy, amount, splits } = expense;
    balances[paidBy] = (balances[paidBy] || 0) + amount;
    splits.forEach((split) => {
      balances[split.memberId] = (balances[split.memberId] || 0) - split.amount;
    });
  });

  return balances;
}

function minimizeCashFlow(balances) {
  const debtors = [];
  const creditors = [];

  Object.entries(balances).forEach(([person, balance]) => {
    if (balance < -0.01) {
      debtors.push({ person, amount: Math.abs(balance) });
    } else if (balance > 0.01) {
      creditors.push({ person, amount: balance });
    }
  });

  debtors.sort((a, b) => b.amount - a.amount);
  creditors.sort((a, b) => b.amount - a.amount);

  const settlements = [];
  let i = 0;
  let j = 0;

  while (i < debtors.length && j < creditors.length) {
    const transferAmount = Math.min(debtors[i].amount, creditors[j].amount);

    settlements.push({
      from: debtors[i].person,
      to: creditors[j].person,
      amount: Math.round(transferAmount * 100) / 100,
    });

    debtors[i].amount -= transferAmount;
    creditors[j].amount -= transferAmount;

    if (debtors[i].amount < 0.01) i++;
    if (creditors[j].amount < 0.01) j++;
  }

  return settlements;
}

Your settlement algorithm is complete. Next up, you'll deploy the full stack to AWS and test every endpoint with real data.

Deploy and Test Your API

Your Lambda functions are written and your SAM template defines the entire infrastructure. The only thing left is to get it all running on AWS.

In this step, you will package your code, deploy the full stack to AWS, and test every endpoint to prove the debt simplification algorithm works in the cloud.

In this step, get ready to:
  • Build and deploy the full stack to AWS with SAM.
  • Test all five API endpoints with curl.
  • Verify the algorithm eliminates intermediary transactions with a triangle scenario.
Build and deploy with SAM

SAM packages your Lambda code into zip archives and uploads them to an S3 bucket it manages. Then it creates a CloudFormation stack with all the resources defined in your template.

  • Make sure your terminal is inside the splitwise-api folder, then build your Lambda functions by running this command:
sam build

What does this command do?

The sam build command reads your template.yaml, finds each function's CodeUri, and packages the source into a .aws-sam/build directory ready for deployment.

You should see output confirming all three functions were built successfully.

Build failed?

Make sure your terminal is inside the splitwise-api directory (the one containing template.yaml). SAM looks for the template in the current directory.

If you see a file-not-found error, double check that your src/groups/, src/expenses/, and src/settle/ folders each contain an index.mjs file.

Help me fix my sam build error.

  • Deploy the stack to AWS by running this command:
sam deploy --guided

What will SAM ask me?

The guided deployment walks you through a series of prompts. Here is how to answer each one:

  • Stack Name: enter splitwise-api.
  • AWS Region: press Enter to accept your default region, or type a region like us-east-1.
  • Confirm changes before deploy: enter y.
  • Allow SAM CLI IAM role creation: press Enter to accept the default (Y).
  • Disable rollback: press Enter to accept the default (N).
  • For each function authorization prompt (e.g. GroupsFunction may not have authorization defined, Is this okay?): enter y for each one.
  • Save arguments to configuration file: press Enter to accept the default (Y).
  • SAM configuration file and environment: press Enter for both to accept the defaults.

Why allow functions without authorization?

SAM detects that your API Gateway endpoints are publicly accessible with no auth configured. For a learning project this is fine. Entering y tells SAM you intentionally want a public API.

  • SAM will show you a changeset (the list of resources to create). When prompted to deploy the changeset, enter y.
  • Wait for the deployment to complete. This takes 1-2 minutes.
  • When deployment finishes, look for the Outputs section at the bottom. Copy the ApiUrl value. It looks like https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com.

Deployment failed?

If you see Error: Security Constraints Not Satisfied, you entered N at one of the authorization prompts. Run sam deploy --guided again and enter y when asked about functions without authorization.

If you see a credentials error, confirm your AWS CLI is configured. Run aws configure and enter your access key and secret key.

Help me troubleshoot my SAM deployment error.

Test the full flow with curl

Your API is live. Time to exercise every endpoint with real HTTP requests to confirm the Lambda functions, API Gateway routes, and DynamoDB tables all work together.

  • Create a group by running this command (replace the URL with your ApiUrl from the deployment output):
curl -X POST [[API_URL="your API Gateway URL"]]/groups \
  -H "Content-Type: application/json" \
  -d '{"name": "Trip Friends", "members": ["Alice", "Bob", "Charlie"]}'

What should I see?

You should get a JSON response with a groupId (a UUID), the name, members array, and a createdAt timestamp. Copy the groupId value for the next commands.

  • Verify you can retrieve the group by running this command (replace the group ID with the one from the previous response):
curl [[API_URL="your API Gateway URL"]]/groups/[[GROUP_ID="your groupId from the previous response"]]

You should see the same group object returned with a 200 status.

  • Add an expense to the group by running this command:
curl -X POST [[API_URL="your API Gateway URL"]]/groups/[[GROUP_ID="your groupId"]]/expenses \
  -H "Content-Type: application/json" \
  -d '{"description": "Dinner", "amount": 30, "paidBy": "Alice", "splits": [{"memberId": "Alice", "amount": 10}, {"memberId": "Bob", "amount": 10}, {"memberId": "Charlie", "amount": 10}]}'

What does this expense represent?

Alice paid 30 for dinner, split equally among Alice, Bob, and Charlie (10 each). After this expense, Bob owes Alice 10 and Charlie owes Alice 10.

  • Get the settlements for this group by running:
curl [[API_URL="your API Gateway URL"]]/groups/[[GROUP_ID="your groupId"]]/settle

You should see a response with balances showing Alice at +20, Bob at -10, and Charlie at -10. The settlements array should show Bob pays Alice 10 and Charlie pays Alice 10.

Getting an empty settlements array?

Make sure the groupId in your settle URL matches the one you used when adding the expense. The settle endpoint queries expenses by groupId, so a mismatched ID returns zero expenses.

If you see a 502 error, check that your expense body includes the splits array with memberId and amount fields for each member.

Help me debug my curl requests to the debt simplification API.

Test the triangle scenario

The real power of the min-cash-flow algorithm is eliminating intermediary payments. If A owes B and B owes C, the algorithm should simplify this to A paying C directly.

  • Create a new group for the triangle test by running:
curl -X POST [[API_URL="your API Gateway URL"]]/groups \
  -H "Content-Type: application/json" \
  -d '{"name": "Triangle Test", "members": ["A", "B", "C"]}'
  • Copy the new groupId from the response.
  • Add the first expense (B paid 10, split to A) by running:
curl -X POST [[API_URL="your API Gateway URL"]]/groups/[[TRIANGLE_GROUP_ID="your triangle test groupId"]]/expenses \
  -H "Content-Type: application/json" \
  -d '{"description": "Expense 1", "amount": 10, "paidBy": "B", "splits": [{"memberId": "A", "amount": 10}]}'
  • Add the second expense (C paid 10, split to B) by running:
curl -X POST [[API_URL="your API Gateway URL"]]/groups/[[TRIANGLE_GROUP_ID="your triangle test groupId"]]/expenses \
  -H "Content-Type: application/json" \
  -d '{"description": "Expense 2", "amount": 10, "paidBy": "C", "splits": [{"memberId": "B", "amount": 10}]}'

What do these expenses create?

After these two expenses: A owes B 10 (B paid, A was split). B owes C 10 (C paid, B was split). Without simplification you would need two transactions. The algorithm should reduce this to one: A pays C 10 directly.

  • Get the settlements to see the simplified result:
curl [[API_URL="your API Gateway URL"]]/groups/[[TRIANGLE_GROUP_ID="your triangle test groupId"]]/settle

The response should show A pays C 10. The intermediary B is eliminated entirely because B's debt to C cancels out what A owes B.

Proving the algorithm works

This triangle scenario is the classic test for debt simplification. Without the algorithm, you would need 2 transactions (A to B, B to C). With it, you only need 1 (A to C). This scales up to any number of group members.

Seeing two settlements instead of one?

Double check that the paidBy and splits fields are correct. In expense 1, paidBy should be B with the split going to A. In expense 2, paidBy should be C with the split going to B.

Help me understand why my triangle test is not producing the expected single settlement.

Your API is live and proven. The greedy min-cash-flow algorithm correctly eliminates intermediary transactions, and all five endpoints are working in the cloud. Next up, you will tackle the Secret Mission and add a visualization endpoint.

Secret mission

Add Settlement Visualization

Your settle endpoint returns the final answer, but users can't see how the algorithm got there. Add a new endpoint that shows each iteration of the greedy algorithm step by step, so users can watch debts simplify in real time.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

All resources in this project are within the AWS Free Tier. DynamoDB uses PAY_PER_REQUEST billing with 25 WCU and 25 RCU free, Lambda includes 1 million free requests per month, and API Gateway includes 1 million HTTP API calls per month for 12 months. This project costs $0 for personal use.

Resources you used:

  • 1 CloudFormation stack (splitwise-api).
  • 2 DynamoDB tables (splitwise-api-groups and splitwise-api-expenses, PAY_PER_REQUEST billing).
  • 4 Lambda functions (GroupsFunction, ExpensesFunction, SettleFunction, SettleVizFunction).
  • 1 HTTP API Gateway with 6 routes.
  • Local project directory (splitwise-api on your Desktop).
  • AWS SAM CLI installed on your machine.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep the API available for testing.

  • All resources are serverless and cost nothing at rest. If nobody calls the API, you incur zero charges.
  • DynamoDB only charges for read/write requests, and Lambda only charges per invocation. No traffic means no cost.
  • The AWS SAM CLI stays installed on your machine for future projects.

Pause - I'll come back to this later

Since all resources are serverless and cost nothing at rest, there is no difference between pausing and keeping. The resources sit idle with zero charges until you invoke them again.

  • Leave the CloudFormation stack deployed. It costs nothing when idle.
  • Your API endpoint remains active. You can call it again anytime.
  • If you want to free up local disk space, you can delete the splitwise-api folder from your Desktop. You can always re-clone or recreate it later since the deployed stack is independent of your local files.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes the CloudFormation stack, all Lambda functions, DynamoDB tables, and the API Gateway.

  • Navigate to your splitwise-api folder and delete the CloudFormation stack by running:
cd ~/Desktop/splitwise-api
sam delete

What does this command do?

The sam delete command deletes your deployed application by removing the CloudFormation stack, all Lambda functions, DynamoDB tables, the API Gateway, and any packaged artifacts in S3. It will prompt you to confirm before deleting.

  • Confirm the deletion when prompted. SAM will ask you to verify the stack name and confirm you want to proceed.
  • Delete the local project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf splitwise-api
  • On Windows, run:
cd ~\Desktop
rmdir /s /q splitwise-api
  • To uninstall the AWS SAM CLI, search for Add or remove programs from the Start menu.
  • Find AWS SAM Command Line Interface in the list, click Uninstall, and confirm.

That's a Wrap!

That's a Wrap!

Nice work! You just built and deployed a production-ready serverless REST API on AWS that solves the debt simplification problem with an efficient greedy algorithm.

You've learned how to:

  • Built a serverless REST API with AWS Lambda, API Gateway, and DynamoDB, deployed as infrastructure-as-code using AWS SAM.
  • Implemented the greedy min-cash-flow algorithm that minimizes debt transactions and proved it works by simplifying triangle debts (A pays B pays C becomes A pays C directly).
  • Designed efficient DynamoDB tables with composite keys for group-scoped expense queries, using PAY_PER_REQUEST billing to stay within the always-free tier.
  • Secret Mission: Added a step-by-step settlement visualization endpoint that shows each iteration of the algorithm as debts get simplified.

Ready to quiz yourself?