Integrating Payment Logging into AWS Lambda + CloudFront: A Case Study in Serverless SPA Architecture
What Was Done
We extended a serverless event-management application (Ship Captain Crew) to support payment logging for patrons. This required:
- Adding Gmail credential injection into a Lambda environment to enable transactional email
- Implementing a new
handle_payment_loggedLambda handler to persist payment records to DynamoDB - Building a "Log Payment" modal UI in the dispatch HTML (S3-hosted SPA)
- Wiring a new CloudFront behavior to route payment endpoints to the Lambda Function URL
- Fixing a discovered routing bug where waiver requests were falling through to S3
Technical Details
Lambda Payment Handler Implementation
The Lambda function at /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py needed a new payment logging endpoint. We added:
def handle_payment_logged(event, context):
"""
POST /api/payment-logged
Receives: { event_id, patron_name, amount, payment_method, notes }
Returns: { success: bool, message: str, payment_id: str }
"""
# Extract auth token from headers
# Validate against ADMIN_PASS_HASH env var
# Insert record into shipcaptaincrew DynamoDB table
# Send confirmation email via SES + Gmail credentials
# Return payment ID + success status
Why this pattern? The existing Lambda already handled event management, authentication, and DynamoDB writes. Co-locating payment logging here avoided introducing a second function (reducing cold-start latency and operational overhead) and reused established auth middleware.
Gmail Credential Injection
The application uses AWS SES for transactional emails but needed Gmail SMTP credentials for enhanced deliverability. We:
- Generated Gmail App Password (stored securely, not in repo)
- Added to Lambda environment variables via
aws lambda update-function-configuration:GMAIL_USERGMAIL_PASSWORDGMAIL_SMTP_HOST=smtp.gmail.comGMAIL_SMTP_PORT=587
- Created helper function in Lambda to abstract SMTP logic
Why not just SES? SES alone works, but Gmail credentials provide a fallback and are required if the application later needs to send from a Gmail-authenticated address for compliance reasons.
CloudFront Routing Fix
During reconnaissance, we discovered a critical routing bug: requests to /g/2026-05-23/waiver were being routed to S3 instead of the Lambda Function URL. The dispatch SPA would then try to parse 2026-05-23/waiver as an event ID, fetch /api/g/2026-05-23/waiver, receive HTML from the correct Lambda handler, and fail when trying to parse HTML as JSON.
The fix involved adding a new CloudFront behavior in the shipcaptaincrew distribution:
Path Pattern: /g/*/waiver
Origin: Lambda Function URL (not S3)
Compress: Yes
Cache TTL: 0 (bypass caching for dynamic waiver forms)
This ensures waiver requests hit handle_waiver_get (line 1697 in lambda_function.py) directly, which returns properly-formatted HTML.
Infrastructure Changes
DynamoDB Schema
The shipcaptaincrew DynamoDB table already had the structure to support payment records. We added a new record type with:
pk:EVENT#{event_id}sk:PAYMENT#{uuid}timestamp: ISO 8601 datetimepatron_name: Stringamount_cents: Integer (stored as cents to avoid float precision issues)payment_method: String (e.g., "cash", "venmo", "check")notes: String (optional memo)logged_by: Email of admin who recorded payment
S3 Bucket
The dispatch HTML is stored at s3://shipcaptaincrew-[account-id]/dispatch.html. We pulled the current production version (2463 lines) before making edits to ensure we didn't lose existing functionality.
Lambda Environment & Deployment
Updated Lambda configuration via:
aws lambda update-function-configuration \
--function-name shipcaptaincrew \
--environment Variables={GMAIL_USER=...,GMAIL_PASSWORD=...,GMAIL_SMTP_HOST=...}
aws lambda update-function-code \
--function-name shipcaptaincrew \
--zip-file fileb://lambda_deployment.zip
Both updates were staged and waited for CloudWatch to confirm deployment completion before smoke testing.
Key Decisions
Why Not a Separate Lambda for Payments?
We could have created shipcaptaincrew-payments as a separate function. However:
- Shared Auth: Payment endpoints reuse the existing admin token validation
- DynamoDB Access: Same table, same IAM role
- Email Infrastructure: Same SES/Gmail credentials
- Cold Starts: One warm function is faster than two cold ones
A separate function would only be justified if payment logging had different scaling characteristics or required different permissions.
Why Store Amounts in Cents?
JavaScript numbers lose precision with floating-point currency (e.g., 0.1 + 0.2 !== 0.3). Storing cents as integers eliminates this problem and is industry standard for financial systems.
Why CloudFront Behavior Before Fix?
We added the waiver behavior before testing payment logging because CloudFront routing errors cascade into the SPA's error handling, making the payment feature appear broken when the real issue was upstream. Fixing infrastructure first meant cleaner test results.
What's Next
Once this deploys to staging and is validated:
- Email notification to CB confirming payment logging is ready for admin use
- Update admin dashboard kanban board to mark this task complete
- Monitor CloudWatch Logs for Lambda errors in the new
handle_payment_loggedhandler - Consider adding a payment audit trail (read-only view showing all logged payments for an event)
- Fix the slug naming convention: event IDs should follow
YYYY-MM-DD-firstname-[period],