```html

Building a Serverless Payment Logging System for AWS Lambda + CloudFront: Architecture and Implementation

What Was Done

We architected and deployed a payment logging system for the Ship Captain Crew tool, integrating Gmail token retention, admin authentication endpoints, and a patron payment modal into an existing AWS Lambda-backed single-page application. The implementation involved coordinating changes across three layers: Lambda function handlers (Python), CloudFront routing rules, and client-side dispatch HTML, while maintaining backward compatibility with existing waiver and event management features.

Technical Details: Lambda Architecture

The core work centered on /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py, which serves as the primary request router for the Ship Captain Crew admin API.

Handler Pattern and Routing

The Lambda function uses a centralized routing pattern that parses incoming CloudFront requests:


# Simplified routing structure
if path == "/api/events":
    return handle_list_events(event, db_client)
elif path.startswith("/g/") and path.endswith("/waiver"):
    return handle_waiver_get(event, db_client)
elif path.startswith("/admin/"):
    return handle_admin_request(event, db_client)

We identified that the existing handle_waiver_get function at line 1697 was returning HTML correctly, but CloudFront was routing /g/*/waiver requests to S3 instead of the Lambda origin. This caused the dispatch SPA to receive HTML, attempt to parse it as JSON via r.json(), and throw a "Could not load event" error. The fix required adding a CloudFront behavior rule: /g/*/waiver → Lambda Function URL with higher priority than the S3 catch-all.

Payment Handler Integration

We inserted new handler functions before the main lambda_handler definition to process payment logging requests:


def handle_payment_log(event, db_client, body_dict):
    """Log patron payment to DynamoDB payment_cleared table."""
    # Extract patron_id, amount, timestamp from body_dict
    # Write to DynamoDB table with transact_write_items for atomicity
    # Return success/error response with proper CORS headers

The DynamoDB table schema (verified via describe_table) stores payment records with a partition key of patron_id and sort key of timestamp, allowing efficient queries of payment history per patron.

Environment Variable Strategy for Credentials

Gmail token credentials and admin password hashes are stored as Lambda environment variables rather than hardcoded in the function code. This follows the principle of separation of concerns: deployment infrastructure (Terraform or AWS CloudFormation) manages secrets, while the Lambda code reads them at runtime:


import os

GMAIL_TOKEN_REFRESH = os.environ.get('GMAIL_TOKEN_REFRESH')
GMAIL_TOKEN_ACCESS = os.environ.get('GMAIL_TOKEN_ACCESS')
SCC_ADMIN_PASS_HASH = os.environ.get('ADMIN_PASS_HASH')

When updating Lambda configuration, we merged the existing environment variables with new ones using a structured payload, then deployed via the Lambda configuration update API to avoid overwriting existing credentials.

Client-Side Implementation: Dispatch HTML

The dispatch HTML at /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/index.html underwent significant iteration (23+ edits) to integrate the payment modal and authentication flow.

Modal Architecture

We followed the existing modal pattern in the SPA, which uses an active CSS class to toggle visibility rather than display: none manipulation:


<div id="payment-modal" class="modal">
  <div class="modal-content">
    <h3>Log Payment for Patron</h3>
    <input type="text" id="patron-id" placeholder="Patron ID" />
    <input type="number" id="payment-amount" placeholder="Amount" />
    <button onclick="submitPayment()">Confirm</button>
  </div>
</div>

The modal is toggled by adding/removing the active class, which is more maintainable than inline style manipulation and aligns with existing UI patterns in the codebase.

API Integration

We located the existing apiFetch helper function and payment-related card rendering logic to understand the authentication token flow. All API requests include the admin session token in the Authorization header:


function apiFetch(path, method = 'GET', body = null) {
    const headers = {
        'Authorization': `Bearer ${adminToken}`,
        'Content-Type': 'application/json'
    };
    return fetch(`/api${path}`, { method, headers, body: body ? JSON.stringify(body) : null })
        .then(r => r.json());
}

The payment submission function calls a new endpoint: POST /api/admin/payment/log, which requires valid admin authentication. The response updates the UI state and displays a confirmation banner using the existing banner infrastructure.

Infrastructure: CloudFront and S3 Coordination

The Ship Captain Crew tool is deployed across multiple AWS resources:

  • S3 Bucket: queenofsandiego.com/tools/shipcaptaincrew/ — serves dispatch HTML and static assets
  • Lambda Function URL: Handles dynamic API requests and waiver/event generation
  • CloudFront Distribution: Routes requests based on path patterns
  • DynamoDB Table: Stores events, waivers, and payment records

CloudFront Behavior Rules

We identified and corrected a routing gap: requests to /g/*/waiver were being forwarded to S3 instead of Lambda. The fix involved creating a new CloudFront behavior with higher priority:


Path Pattern: /g/*/waiver
Origin: Lambda Function URL
Allowed Methods: GET, HEAD, OPTIONS
Viewer Protocol Policy: Redirect HTTP to HTTPS

This behavior must appear before the S3 origin catch-all in the CloudFront distribution to ensure correct routing precedence.

Cache Invalidation Strategy

After deploying updated dispatch HTML to the staging slot at /_staging/index.html, we invalidated the CloudFront cache with the path /_staging/* to ensure users receive the latest version immediately. This is critical during development iterations where the same URL serves different code.

Key Decisions and Rationale

Why Centralized Lambda Routing?

Rather than deploying separate Lambda functions for each endpoint, we maintained a single function with a routing dispatcher. This reduces operational overhead (one function to version, monitor, and debug) and enables atomic transactions across multiple DynamoDB tables when needed.

Why Environment Variables for Secrets?

Storing Gmail tokens