An enterprise-grade, event-driven payment backend engine emulating core ledger transactions. Built using Python 3.11, AWS Lambda, Amazon API Gateway, and DynamoDB Local, fully orchestrated via Docker and AWS SAM CLI for reproducible local evaluation without an AWS account.
+----------------------------------------------+
| Client Request |
| (Authorization: Bearer <JWT> | SigV4) |
+----------------------------------------------+
|
v
+----------------------------------------------------------------------------------------+
| Amazon API Gateway |
| |
| [POST /admin/topup] [GET /me/balance] |
| (Auth: AWS_IAM) [POST /transfers] |
| | (Auth: Lambda Token Authorizer) |
| | | |
| | v |
| | +-----------------------+ |
| | | AuthorizerFunction | |
| | | - HMAC-SHA256 Verify | |
| | | - Scope Verification | |
| | | - IAM Policy Emit | |
| | +-----------------------+ |
| | | |
| | v Context Injection |
| v v (userId, scopes) |
| +-----------------------+ +-----------------------+ |
| | AdminTopupFunction | | Balance / Transfer | |
| | (System Credits) | | Handler Functions | |
| +-----------------------+ +-----------------------+ |
+----------------------------------------------------------------------------------------+
|
v
+----------------------------------------------------------------------------------------+
| Amazon DynamoDB |
| |
| TransactWriteItems (ACID 4-Way Transaction) |
| --------------------------------------------------------------------------------- |
| 1. IDEMPOTENCY#<key> -> Condition: attribute_not_exists(pk) [Double-Charge Guard] |
| 2. USER#<sender> -> Condition: #st = ACTIVE AND balance >= :amount |
| 3. USER#<receiver> -> Condition: attribute_exists(pk) AND #st = ACTIVE |
| 4. TX#<tx_id> -> Ledger audit entry record insertion |
+----------------------------------------------------------------------------------------+
Client bodies are strictly untrusted for identity claims. The sender username is never parsed from the request body on transfer routes. Instead, the Lambda Authorizer validates the JWT signature, unpacks the subject (sub), evaluates route-level scopes (wallet:read, wallet:transfer), and injects the verified identity into requestContext.authorizer.userId. Downstream handlers consume this trusted context exclusively.
Distributed ledger architectures require strict prevention of double-spends and race conditions. Rather than using distributed locks or relational table deadlocks:
- Debit, credit, idempotency reservation, and ledger logging are bundled into a single 4-item atomic transaction.
- If a sender balance drops below the transfer threshold, or if either party is marked
INACTIVE/FROZEN, DynamoDB atomically aborts the entire transaction batch with zero partial state. - In-flight concurrent requests on the same wallet result in a
TransactionCanceledException, mapped to409 Conflictto safely trigger client retries.
Network timeouts between client and gateway risk duplicate charges upon retry.
- Transfers require an
Idempotency-Keyheader. - The transaction reserves
IDEMPOTENCY#<key>with a condition checkattribute_not_exists(pk). - If an operation with the same key was previously processed for that user, the engine short-circuits execution and returns a replay response (
200 OKwithDUPLICATE_ACCEPTED), protecting against duplicate ledger deductions.
To maintain WalletEngine:
| Entity Pattern | Partition Key (pk) |
Sort Key (sk) |
Attributes |
|---|---|---|---|
| User Wallet | USER#<username> |
WALLET |
balance (Number), status (String), updated_at |
| Idempotency Lock | IDEMPOTENCY#<key> |
IDEMPOTENCY |
caller_id (String), status (String), created_at |
| Ledger Audit Log | TX#<key> |
LOG |
from_user, to_user, amount, timestamp |
- User APIs (
GET /me/balance,POST /transfers): Protected by a custom Lambda Authorizer verifying Bearer JWTs and claims. - System APIs (
POST /admin/topup): Protected by nativeAWS_IAMauthorization in the CloudFormation definition, ensuring internal back-office maintenance can only be executed by callers with explicit AWS SigV4 STS credentials.
Before starting, ensure your system has the following running:
- Docker & Docker Compose (Docker Engine running)
- AWS SAM CLI (
sam --version>= 1.110.0) - Python 3.10+ &
make
(Note: No AWS account or cloud credentials are required; all AWS services are emulated locally.)
Run the following in your terminal to spin up the local DynamoDB container and seed test wallets:
# 1. Install local test suite dependencies (requests, pyjwt)
make setup
# 2. Start DynamoDB Local container
make up
# 3. Initialize DynamoDB schema and seed test accounts
make seedSeeded Test Accounts:
alice: Balance: $1000 | Status:ACTIVEbob: Balance: $250 | Status:ACTIVEcharlie_inactive: Balance: $500 | Status:INACTIVE(Frozen Account)
In Terminal 1, run:
make startWhat happens behind the scenes:
- Runs
sam build --use-container, pulling the official AWS Lambda Linux build image to package handlers and resolve dependencies cleanly. - Mounts the compiled artifacts into an emulated API Gateway server at
http://127.0.0.1:3000. - Bridges the Lambda containers to the
wallet-netDocker network to enable communication withdynamodb-local:8000.
Open Terminal 2 and execute the automated test suite:
make testExpected Verification Output:
--- 1. Testing GET /me/balance (Alice) ---
Status: 200, Body: {"userId": "alice", "balance": 1000, "status": "ACTIVE"}
--- 2. Testing Scope Deny (Missing wallet:read) ---
Status: 403, Body: {"message":"User is not authorized to access this resource"}
--- 3. Testing POST /transfers (Alice -> Bob: $100) ---
Status: 201, Body: {"status": "SUCCESS", "from": "alice", "to": "bob", "amount": 100, "idempotencyKey": "tx-uuid-001"}
--- 4. Testing Idempotency (Replaying same key) ---
Status: 200, Body: {"status": "DUPLICATE_ACCEPTED", "message": "Transfer previously completed", "idempotencyKey": "tx-uuid-001"}
--- 5. Testing Inactive Wallet (Alice -> Charlie: $50) ---
Status: 403, Body: {"error": "Wallet for receiver (charlie_inactive) is inactive or frozen"}
--- 6. Testing Admin Topup (Bypasses JWT) ---
Status: 200, Body: {"status": "TOPUP_SUCCESS", "userId": "alice", "newBalance": 1400}
To run manual calls via cURL, Postman, or Thunder Client, first generate a signed development token:
export TOKEN=$(python3 -c "import jwt, time; print(jwt.encode({'sub': 'alice', 'scopes': ['wallet:read', 'wallet:transfer'], 'iat': int(time.time()), 'exp': int(time.time())+86400}, 'local-dev-jwt-secret-key-32-bytes!', algorithm='HS256'))")Retrieves the balance for the authenticated user context.
Headers:
Authorization: Bearer <TOKEN>
cURL:
curl -X GET http://127.0.0.1:3000/me/balance \
-H "Authorization: Bearer $TOKEN"Response (200 OK):
{
"userId": "alice",
"balance": 1400,
"status": "ACTIVE"
}Executes an atomic transfer from caller to recipient.
Headers:
Authorization: Bearer <TOKEN>Idempotency-Key: <unique-string>Content-Type: application/json
Payload:
{
"to": "bob",
"amount": 100
}cURL:
curl -X POST http://127.0.0.1:3000/transfers \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: demo-key-101" \
-H "Content-Type: application/json" \
-d '{"to": "bob", "amount": 100}'Response (201 Created):
{
"status": "SUCCESS",
"from": "alice",
"to": "bob",
"amount": 100,
"idempotencyKey": "demo-key-101"
}Replay Behavior (200 OK):
Submitting the exact same command again yields:
{
"status": "DUPLICATE_ACCEPTED",
"message": "Transfer previously completed",
"idempotencyKey": "demo-key-101"
}Credits a user balance directly. Configured with native AWS IAM authorization (SigV4).
Headers:
Content-Type: application/json
(Bypasses JWT Bearer Token Authorizer)
Payload:
{
"userId": "alice",
"amount": 500
}cURL:
curl -X POST http://127.0.0.1:3000/admin/topup \
-H "Content-Type: application/json" \
-d '{"userId": "alice", "amount": 500}'Response (200 OK):
{
"status": "TOPUP_SUCCESS",
"userId": "alice",
"newBalance": 1900
}The application maps internal transaction failures to RFC-compliant HTTP status codes:
| HTTP Status | Error Scenario | Trigger Condition |
|---|---|---|
400 Bad Request |
Input Validation Error | Missing headers, negative amount, self-transfers, or malformed JSON |
401 Unauthorized |
Token Failure | Expired JWT signature, invalid formatting, or missing bearer token |
403 Forbidden |
Scope Denied / Account Frozen | Missing wallet:transfer scope or target account is marked INACTIVE |
404 Not Found |
Missing Entity | Recipient or sender username does not exist in ledger table |
409 Conflict |
Transaction Race / Key Conflict | Concurrent modification conflict or foreign idempotency key collision |
422 Unprocessable |
Insufficient Funds | Sender wallet balance is lower than the requested transfer amount |
500 Server Error |
Unhandled System Fault | Downstream DynamoDB client disconnect or unexpected runtime fault |
wallet-api-local-docker/
├── docker-compose.yml # DynamoDB Local definition with bridge network
├── template.yaml # CloudFormation / SAM Infrastructure & IAM policies
├── env.json # SAM environment variable overrides
├── Makefile # Automated commands for developer workflow
├── requirements.txt # Runtime Lambda dependencies (pyjwt)
├── src/
│ ├── authorizer/
│ │ └── app.py # Custom Token Authorizer & IAM Policy Generator
│ ├── handlers/
│ │ ├── balance.py # GET /me/balance handler
│ │ ├── transfer.py # POST /transfers handler
│ │ └── admin_topup.py # POST /admin/topup handler (IAM auth)
│ └── shared/
│ ├── db.py # TransactWriteItems builder & DynamoDB logic
│ ├── errors.py # Domain exceptions & HTTP error mapper
│ └── utils.py # Response serializer & test JWT generator
└── tests/
├── seed_data.py # DynamoDB table initialization & seed accounts
└── test_api.py # Complete integration test suite
To stop containers and wipe local database state:
make clean