Docs/ Use Cases/ Payment Ledger

Build an idempotent payment ledger and iterate it on your laptop

You are a backend engineer at a fintech-adjacent product. Users need to be able to send each other money. The product manager wants the obvious shape: POST a payment, debit the payer, credit the payee, record two ledger rows so accountants can reconcile. Easy in a SQL database with a transaction. On AWS, where the team has standardised on DynamoDB, it is slightly less easy: the wrong shape of code under load produces double-debits, lost credits, and overdrawn balances that have to be fixed by hand.

The two correctness properties the system has to satisfy are boring and absolute. Idempotency: if the client retries the same payment, exactly one transfer happens; the retry returns the same response as the original. Atomicity: a payment either commits all five effects (claim idempotency key, debit payer, credit payee, write debit ledger row, write credit ledger row) or it commits none of them. There is no in-between state where alice was debited but bob was not credited. DynamoDB's TransactWriteItems gives you both for free as long as you use it correctly.

This tutorial builds the API on LocalEmu in about a minute, sends three real payment scenarios through it from the AWS CLI, and inspects the DynamoDB tables to prove the invariants actually hold. 16 Terraform resources including a 5-way concurrent-debit race test, no AWS account.

What you will have working at the end

Three real curl scenarios against the running API, with the real captured responses later on this page:

Architecture

client ──POST /payments──▶ API Gateway v2
                                    │  Idempotency-Key header
                                    ▼
                            Lambda: payments
                                    │  TransactWriteItems (5 items, atomic)
                                    ▼
       ┌─────────────────────┬──────┴──────┬─────────────────────┐
       ▼                     ▼             ▼                     ▼
dynamodb:           dynamodb:     dynamodb:             eventbridge:
idempotency         accounts      ledger                on success only
(claim key)         (debit + credit) (2 rows: DEBIT + CREDIT)  │
                                                                ▼
                                                       sqs: settled-events
                                                       (+ DLQ for failures)

1. The atomic transaction

Everything about this handler comes down to one TransactWriteItems call with five operations. DynamoDB guarantees that either all five commit or none of them do, and that any ConditionExpression on any item is evaluated as part of the same atomic step. That is the entire correctness story:

src/handler.py (the transaction)
# src/handler.py: the only DynamoDB call in the whole payment path.
# Five operations in one atomic transaction:
#   0) claim the idempotency key (Put with attribute_not_exists)
#   1) debit the payer (Update with balance >= amount AND status = active)
#   2) credit the payee (Update with attribute_exists AND status = active)
#   3) write the DEBIT ledger row
#   4) write the CREDIT ledger row
# Either ALL five commit or NONE of them do. DynamoDB enforces this server-side.

_ddb.transact_write_items(TransactItems=[
    {"Put": {                                # [0] idempotency claim
        "TableName": IDEMPOTENCY_TABLE,
        "Item": {"key": {"S": idem_key},
                 "response_body": {"S": response_body},
                 "status_code": {"N": "201"},
                 "tx_id": {"S": tx_id},
                 "created_at": {"N": str(now)},
                 "ttl": {"N": str(now + IDEMPOTENCY_TTL)}},
        "ConditionExpression": "attribute_not_exists(#k)",
        "ExpressionAttributeNames": {"#k": "key"}}},

    {"Update": {                             # [1] payer debit
        "TableName": ACCOUNTS_TABLE,
        "Key": {"account_id": {"S": from_acc}},
        "UpdateExpression": "SET balance = balance - :amt",
        "ConditionExpression":
            "attribute_exists(account_id) AND balance >= :amt AND #s = :active",
        "ExpressionAttributeNames": {"#s": "status"},
        "ExpressionAttributeValues": {":amt": {"N": str(amount)},
                                       ":active": {"S": "active"}}}},

    {"Update": {                             # [2] payee credit
        "TableName": ACCOUNTS_TABLE,
        "Key": {"account_id": {"S": to_acc}},
        "UpdateExpression": "SET balance = balance + :amt",
        "ConditionExpression":
            "attribute_exists(account_id) AND #s = :active",
        "ExpressionAttributeNames": {"#s": "status"},
        "ExpressionAttributeValues": {":amt": {"N": str(amount)},
                                       ":active": {"S": "active"}}}},

    {"Put": {                                # [3] DEBIT ledger row
        "TableName": LEDGER_TABLE,
        "Item": {"account_id": {"S": from_acc}, "tx_id": {"S": tx_id},
                 "leg": {"S": "DEBIT"}, "amount": {"N": str(amount)},
                 "currency": {"S": currency}, "created_at": {"N": str(now)}}}},

    {"Put": {                                # [4] CREDIT ledger row
        "TableName": LEDGER_TABLE,
        "Item": {"account_id": {"S": to_acc}, "tx_id": {"S": tx_id},
                 "leg": {"S": "CREDIT"}, "amount": {"N": str(amount)},
                 "currency": {"S": currency}, "created_at": {"N": str(now)}}}}
])

Three details in those five items are load-bearing. Item [0]'s attribute_not_exists(#k) is what enforces idempotency: a second request with the same idempotency-key cannot get past this condition. Item [1]'s balance >= :amt is what enforces no-overdraw: a debit that would take the balance negative is rejected at this condition. And the fact that items [3] and [4] are bundled into the same transact-write means it is impossible to ever observe a state where alice was debited but the matching ledger row is missing.

2. How a replay returns the original response

On TransactionCanceledException, DynamoDB hands back a per-item CancellationReasons array that says which item failed. The handler dispatches on the position. Position 0 means the idempotency-key already existed: the handler reads the stored response from the idempotency table and returns it as-is. Positions 1 and 2 mean the payer or payee condition failed, which become a clean 422 with no side effects.

src/handler.py (the cancellation branches)
# src/handler.py: how a replay returns the original response.
# When the transaction is cancelled with reason[0] == ConditionalCheckFailed,
# the idempotency-key was already claimed. Read the stored response and
# return it byte-identical: same tx_id, same status_code, same body.

except _ddb.exceptions.TransactionCanceledException as e:
    reasons = e.response.get("CancellationReasons", [])

    # [0] idempotency-key already exists: return the stored response.
    if reasons and reasons[0].get("Code") == "ConditionalCheckFailed":
        stored = _ddb.get_item(TableName=IDEMPOTENCY_TABLE,
                               Key={"key": {"S": idem_key}})["Item"]
        return {"statusCode": int(stored["status_code"]["N"]),
                "headers": {"content-type": "application/json"},
                "body": stored["response_body"]["S"]}

    # [1] payer check failed: insufficient funds or inactive account.
    if len(reasons) > 1 and reasons[1].get("Code") == "ConditionalCheckFailed":
        return _resp(422, {"error": "payer check failed",
                           "detail": "insufficient funds or inactive",
                           "account": from_acc})

    # [2] payee check failed: account does not exist or is inactive.
    if len(reasons) > 2 and reasons[2].get("Code") == "ConditionalCheckFailed":
        return _resp(422, {"error": "payee check failed", "account": to_acc})

The reason a replay sees the exact same response (down to the tx_id and created_at timestamp) is that the original write of the idempotency row stored the whole response JSON in response_body, as part of the atomic transaction. The replay does not recompute anything; it reads back what was written.

3. Run the scenarios on your laptop

Clone the project, start LocalEmu in another terminal, then deploy:

$ git clone https://github.com/localemu/localemu-examples
$ cd localemu-examples/07-payment-ledger
$ localemu start   # in a separate terminal
$ ./scripts/deploy.sh local

Sixteen resources apply in about a minute. Most of the wall time is the two SQS queues (about 25 seconds each because LocalEmu does a full setup of the underlying ElasticMQ backend on first use) plus the Lambda container cold-start:

Terminal: deploy
$ ./scripts/deploy.sh local
aws_dynamodb_table.accounts:                       Creation complete after 0s
aws_dynamodb_table.ledger:                         Creation complete after 0s
aws_dynamodb_table.idempotency:                    Creation complete after 0s
aws_cloudwatch_event_bus.bus:                      Creation complete after 1s
aws_iam_role.lambda_role:                          Creation complete after 0s
aws_sqs_queue.settled_dlq:                         Creation complete after 25s
aws_sqs_queue.settled:                             Creation complete after 25s
aws_sqs_queue_policy.allow_events:                 Creation complete after 25s
aws_lambda_function.payments:                      Creation complete after 11s
aws_apigatewayv2_api.api:                          Creation complete after 1s
aws_apigatewayv2_integration.lambda:               Creation complete after 0s
aws_apigatewayv2_route.post_payments:              Creation complete after 0s
aws_apigatewayv2_stage.default:                    Creation complete after 0s
aws_cloudwatch_event_rule.on_payment_settled:      Creation complete after 0s
aws_cloudwatch_event_target.to_sqs:                Creation complete after 0s
aws_lambda_permission.apigw:                       Creation complete after 0s

Apply complete! Resources: 16 added, 0 changed, 0 destroyed.

 deployed to local. outputs:
accounts_table      = "le-pay-accounts"
api_endpoint        = "https://uztvughm.execute-api.us-east-1.amazonaws.com"
api_id              = "uztvughm"
bus_name            = "le-pay-bus"
idempotency_table   = "le-pay-idempotency"
ledger_table        = "le-pay-ledger"
settled_queue_url   = "http://sqs.us-east-1.localhost:4566/000000000000/le-pay-settled-events"

real    1m03.182s

Now drive the three scenarios from the top of this page by hand. Each scenario shows the response and an inline note on what to verify in the tables afterwards:

Terminal: three real curl scenarios
$ # Seed two accounts: alice with $500, bob with $0, both active.
$ AWS="aws --endpoint-url http://localhost:4566"
$ $AWS dynamodb put-item --table-name le-pay-accounts \
       --item '{"account_id":{"S":"alice"},"balance":{"N":"500"},"status":{"S":"active"}}'
$ $AWS dynamodb put-item --table-name le-pay-accounts \
       --item '{"account_id":{"S":"bob"},"balance":{"N":"0"},"status":{"S":"active"}}'
$ API=http://localhost:4566/_aws/execute-api-v2/uztvughm/\$default


$ ### Scenario A: pay $100 from alice to bob.

$ curl -s -X POST $API/payments \
       -H 'Content-Type: application/json' \
       -H 'Idempotency-Key: idem-demo-1' \
       -d '{"from":"alice","to":"bob","amount":100,"currency":"USD"}' | jq
{
  "tx_id":      "3a373793dd6f441293ebb091083a33d8",
  "from":       "alice",
  "to":         "bob",
  "amount":     100,
  "currency":   "USD",
  "created_at": 1779562613,
  "status":     "settled"
}

$ # Balances after: alice=400, bob=100. Exactly what we expect.


$ ### Scenario B: replay the SAME request with the SAME idempotency key.
$ # Returns the original response byte-identical (same tx_id, same created_at).

$ curl -s -X POST $API/payments \
       -H 'Content-Type: application/json' \
       -H 'Idempotency-Key: idem-demo-1' \
       -d '{"from":"alice","to":"bob","amount":100,"currency":"USD"}' | jq
{
  "tx_id":      "3a373793dd6f441293ebb091083a33d8",
  "from":       "alice",
  "to":         "bob",
  "amount":     100,
  "currency":   "USD",
  "created_at": 1779562613,
  "status":     "settled"
}

$ # Balances unchanged. alice=400, bob=100. The transaction was not re-executed.


$ ### Scenario C: try to pay $600 from alice (who only has $400). 422.

$ curl -s -w "\nHTTP %{http_code}\n" -X POST $API/payments \
       -H 'Content-Type: application/json' \
       -H 'Idempotency-Key: idem-demo-2' \
       -d '{"from":"alice","to":"bob","amount":600,"currency":"USD"}'
{"error": "payer check failed", "detail": "insufficient funds or inactive", "account": "alice"}
HTTP 422

$ # Balances unchanged. No ledger rows written. No idempotency row consumed.
$ # All five operations were rejected together because the payer Update failed.


$ # Inspect the ledger: only the successful transfer wrote rows.
$ $AWS dynamodb scan --table-name le-pay-ledger --output table \
       --query 'Items[*].[account_id.S,leg.S,amount.N,tx_id.S]'
+--------+--------+-------+----------------------------------+
|  alice | DEBIT  |  100  | 3a373793dd6f441293ebb091083a33d8 |
|  bob   | CREDIT |  100  | 3a373793dd6f441293ebb091083a33d8 |
+--------+--------+-------+----------------------------------+

# Two rows, same tx_id. The ledger is double-entry: every transaction is
# two rows (DEBIT on payer, CREDIT on payee) that sum to zero.

The same three scenarios plus six more (input validation + the 5-way concurrent-debit race + the missing-idempotency-key rejection) are baked into pytest, all green in under five seconds:

Terminal: pytest
$ ./scripts/test.sh local

============================= test session starts ==============================
platform darwin -- Python 3.13.12, pytest-9.0.3
collected 9 items

tests/test_payments.py::test_happy_path                              PASSED
tests/test_payments.py::test_idempotent_replay                       PASSED
tests/test_payments.py::test_insufficient_funds_is_atomic            PASSED
tests/test_payments.py::test_concurrent_debits_never_overdraw        PASSED
tests/test_payments.py::test_input_validation[body0-differ]          PASSED
tests/test_payments.py::test_input_validation[body1-positive]        PASSED
tests/test_payments.py::test_input_validation[body2-positive]        PASSED
tests/test_payments.py::test_input_validation[body3-required]        PASSED
tests/test_payments.py::test_missing_idempotency_key                 PASSED

============================== 9 passed in 4.99s ==============================

Tear it back down:

Terminal: teardown
$ ./scripts/teardown.sh local
Destroy complete! Resources: 16 destroyed.

 verifying teardown for prefix 'le-pay' on local
  clean: nothing left behind

real    0m32.827s

Deploy 63 seconds, tests 5 seconds, teardown 33 seconds. About 100 seconds round trip; mostly SQS setup and teardown. Iteration on the handler itself, the IAM policy, or the TransactWriteItems shape happens in seconds: those resources re-apply almost instantly.

4. The same code on real AWS

Same scripts, aws instead of local:

$ ./scripts/deploy.sh aws
$ ./scripts/test.sh     aws
$ ./scripts/teardown.sh aws

Four DynamoDB and Lambda details where the same Terraform behaves differently on real AWS, all worth knowing before you ship a real payment endpoint:

Broader comparison in LocalEmu vs Real AWS and Known Limitations.

Get the full project

git clone https://github.com/localemu/localemu-examples : the payment ledger tutorial lives in 07-payment-ledger/ with the Terraform, the handler, the nine integration tests (including the 5-way concurrent-debit race), and the deploy / test / teardown scripts that produced every terminal output on this page.

Where to go next