AWS Step Functions Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance

AWS Step Functions is a serverless orchestration service for coordinating Lambda functions, ECS tasks, and other AWS services into durable workflows. AI agent teams use Step Functions to build billing pipelines with automatic retry, parallel fan-out via the Map state, and scheduled execution via EventBridge rules. When those workflows include Stripe billing — per-customer subscription renewals fanned out with Map, monthly billing triggered by a cron EventBridge rule, or usage-based charges invoked by an AI agent state — Step Functions introduces three specific failure modes that neither IAM nor Stripe restricted keys prevent on their own.

This post covers all three failure modes with Python Lambda code and Step Functions state machine JSON, and the two-layer governance pattern — content-hash idempotency keys plus per-item vault keys via a spend-cap proxy — that eliminates all three without changing your workflow graph.

Failure mode 1: Retry re-invokes the Lambda from line 1 after a downstream failure that follows a successful Stripe charge

Step Functions' Retry configuration on a Task state re-invokes the Lambda function when the function throws an error matching the ErrorEquals list. This is correct behavior for transient failures — a Lambda cold-start timeout, a DynamoDB connection reset, an SQS unavailability — where invoking the Lambda again from the beginning is safe. The failure mode occurs when the Lambda calls stripe.charges.create(), that call succeeds and returns a charge object, and then a subsequent operation within the same Lambda invocation raises an exception. The Lambda throws; Step Functions sees the error, matches it against the Retry block, and re-invokes the Lambda. The re-invocation starts from the top of the handler function. stripe.charges.create() fires again with no idempotency key, and Stripe creates a second charge for the same customer.

With MaxAttempts: 3 and BackoffRate: 2, a DynamoDB write that intermittently times out after a successful Stripe charge can produce up to three charges before Step Functions exhausts the retry budget. The Step Functions execution history shows three Lambda invocations each marked as TaskFailed (because the DynamoDB write never succeeded), but Stripe shows two or three charges in the succeeded state. The mismatch is invisible until a customer disputes a duplicate payment.

# charge_customer.py — UNSAFE: no idempotency key, downstream failure triggers duplicate charge
import stripe
import boto3
import os

def handler(event, context):
    stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
    dynamodb = boto3.resource("dynamodb")
    table = dynamodb.Table(os.environ["CHARGES_TABLE"])

    customer_id = event["customerId"]
    amount_cents = event["amountCents"]
    billing_period = event["billingPeriod"]

    # UNSAFE: no idempotency key
    # If table.put_item() raises below, Step Functions retries this Lambda.
    # On retry, stripe.Charge.create() fires again → ch_B created.
    charge = stripe.Charge.create(
        amount=amount_cents,
        currency="usd",
        customer=customer_id,
        description=f"Billing {billing_period}",
    )

    # DynamoDB write: if this raises (ProvisionedThroughputExceededException,
    # RequestTimeout), the Lambda throws → Step Functions Retry fires →
    # stripe.Charge.create() fires again on the next invocation.
    table.put_item(Item={
        "customerId": customer_id,
        "billingPeriod": billing_period,
        "chargeId": charge["id"],
        "status": "succeeded",
    })

    return {"chargeId": charge["id"]}

The fix is a content-hash idempotency key derived from the billing inputs — customer ID, amount, and billing period — combined with a workflow-specific salt. This key is computed before the Stripe call and is identical across every Lambda re-invocation with the same inputs. When Step Functions retries after a DynamoDB failure, stripe.Charge.create() fires with the same idempotency key, and Stripe returns the original charge object without creating a new one. The DynamoDB write becomes safe to retry.

Additionally, distinguish permanent Stripe errors (card declined, invalid customer) from transient errors and raise a custom exception that Step Functions can route to a Catch block rather than retrying. Step Functions' Retry configuration supports ErrorEquals filtering — configure it to retry only on Lambda.ServiceException, Lambda.AWSLambdaException, and States.TaskFailed for DynamoDB timeouts, while catching StripePermanentError and routing it to a failure state without retrying.

# charge_customer.py — SAFE: content-hash idempotency key + permanent-error classification
import stripe
import boto3
import os
import hashlib

class StripePermanentError(Exception):
    """Card declined, invalid customer — retrying will not help."""
    pass

class SpendCapExhausted(Exception):
    """Vault key cap hit — review input data before retrying."""
    pass

def billing_idempotency_key(customer_id: str, amount_cents: int, billing_period: str) -> str:
    raw = f"{customer_id}:{amount_cents}:{billing_period}:sfn-billing"
    return hashlib.sha256(raw.encode()).hexdigest()[:32]

def handler(event, context):
    customer_id = event["customerId"]
    amount_cents = event["amountCents"]
    billing_period = event["billingPeriod"]
    vault_key = event["vaultKey"]  # per-item vault key from IssueVaultKeys state

    # Idempotency key is stable across every Lambda re-invocation with same inputs
    idempotency_key = billing_idempotency_key(customer_id, amount_cents, billing_period)

    # Point the Stripe SDK at the Keybrake proxy using the per-item vault key
    stripe.api_key = vault_key
    stripe.api_base = "https://proxy.keybrake.com/stripe"

    try:
        charge = stripe.Charge.create(
            amount=amount_cents,
            currency="usd",
            customer=customer_id,
            description=f"Billing {billing_period}",
            idempotency_key=idempotency_key,
        )
    except stripe.error.CardError as e:
        raise StripePermanentError(f"Card declined: {e.user_message}") from e
    except stripe.error.InvalidRequestError as e:
        raise StripePermanentError(f"Invalid Stripe request: {str(e)}") from e
    except stripe.error.RateLimitError as e:
        # Check for Keybrake spend cap header
        if hasattr(e, "headers") and e.headers.get("x-keybrake-cap-hit") == "true":
            raise SpendCapExhausted("Vault key spend cap exhausted") from e
        raise  # Transient rate limit — safe to retry with same idempotency key

    dynamodb = boto3.resource("dynamodb")
    table = dynamodb.Table(os.environ["CHARGES_TABLE"])
    table.put_item(Item={
        "customerId": customer_id,
        "billingPeriod": billing_period,
        "chargeId": charge["id"],
        "status": "succeeded",
    })

    return {"chargeId": charge["id"]}

In the state machine definition, configure Retry to cover only transient Lambda and service errors, and add a Catch block that routes StripePermanentError and SpendCapExhausted to a dedicated failure state without invoking the retry logic:

{
  "ChargeCustomer": {
    "Type": "Task",
    "Resource": "arn:aws:lambda:us-east-1:123456789012:function:charge-customer",
    "Retry": [
      {
        "ErrorEquals": [
          "Lambda.ServiceException",
          "Lambda.AWSLambdaException",
          "Lambda.SdkClientException",
          "States.TaskFailed"
        ],
        "MaxAttempts": 3,
        "BackoffRate": 2,
        "IntervalSeconds": 2
      }
    ],
    "Catch": [
      {
        "ErrorEquals": ["StripePermanentError", "SpendCapExhausted"],
        "Next": "BillingFailed",
        "ResultPath": "$.error"
      }
    ],
    "Next": "RecordSuccess"
  }
}

Failure mode 2: Map state fan-out shares one unrestricted STRIPE_SECRET_KEY across all concurrent Lambda invocations with no per-item spend cap

Step Functions' Map state distributes an input array across N parallel Lambda invocations. Each invocation is an independent Lambda execution, but all share the same environment variable set — including STRIPE_SECRET_KEY. There is no per-invocation dollar cap in Step Functions: MaxConcurrency controls how many Lambda invocations execute simultaneously, but does not limit how much money each invocation is allowed to charge. A data bug that passes amountCents: 49999 (intended: 4999) to all 500 customers triggers $24,999.50 in charges across the concurrent Map iterations before any result returns to the parent workflow. Because all invocations share the same unrestricted key, the first cap hit in one iteration does not abort the others — the Map state executes all iterations to completion or failure independently, and only then proceeds.

# State machine excerpt — UNSAFE: Map state fans out with shared unrestricted key
{
  "BillingFanOut": {
    "Type": "Map",
    "ItemsPath": "$.customers",
    "MaxConcurrency": 50,
    "Iterator": {
      "StartAt": "ChargeCustomer",
      "States": {
        "ChargeCustomer": {
          "Type": "Task",
          "Resource": "arn:aws:lambda:us-east-1:123456789012:function:charge-customer",
          "End": true
        }
      }
    },
    "Next": "RecordBillingRun"
  }
}

The fix is to add an IssueVaultKeys Lambda state before the Map state. This Lambda fetches the customer list, issues one vault key per customer from the Keybrake proxy — each capped at the customer's expected charge amount plus a 10% buffer, scoped to POST /v1/charges, with a TTL that covers the Map fan-out duration — and returns the enriched customer list with each item carrying its own vault key. The Map state then distributes this enriched list. Each Lambda invocation receives a unique vault key in its event payload and uses that key to initialize the Stripe client, not the environment variable. A data bug that passes an oversized amount is caught by the per-item vault key cap on the first Stripe call for that customer — the proxy returns a 429 with the x-keybrake-cap-hit header before the charge is created. Because each iteration has its own key, the cap hit for one customer does not affect the others.

# issue_vault_keys.py — run as a Lambda state before the Map fan-out
import os
import json
import urllib.request

KEYBRAKE_API_KEY = os.environ["KEYBRAKE_API_KEY"]
KEYBRAKE_PROXY_URL = os.environ.get("KEYBRAKE_PROXY_URL", "https://proxy.keybrake.com")

def handler(event, context):
    customers = event["customers"]
    billing_period = event["billingPeriod"]
    enriched = []

    for customer in customers:
        customer_id = customer["customerId"]
        amount_cents = customer["amountCents"]
        # Cap = expected charge + 10% buffer; scope to charges POST only
        daily_usd_cap = int((amount_cents * 1.1) / 100) + 1

        body = json.dumps({
            "vendor": "stripe",
            "daily_usd_cap": daily_usd_cap,
            "allowed_endpoints": ["POST /v1/charges"],
            "expires_in_seconds": 3600,
            "label": f"sfn/{billing_period}/{customer_id}",
        }).encode()

        req = urllib.request.Request(
            f"{KEYBRAKE_PROXY_URL}/vault/keys",
            data=body,
            headers={
                "Authorization": f"Bearer {KEYBRAKE_API_KEY}",
                "Content-Type": "application/json",
            },
            method="POST",
        )
        with urllib.request.urlopen(req) as resp:
            vault_key = json.loads(resp.read())["vault_key"]

        enriched.append({**customer, "vaultKey": vault_key, "billingPeriod": billing_period})

    return {"customers": enriched, "billingPeriod": billing_period}
# State machine excerpt — SAFE: IssueVaultKeys before Map, each item carries its own vault key
{
  "IssueVaultKeys": {
    "Type": "Task",
    "Resource": "arn:aws:lambda:us-east-1:123456789012:function:issue-vault-keys",
    "Next": "BillingFanOut"
  },
  "BillingFanOut": {
    "Type": "Map",
    "ItemsPath": "$.customers",
    "MaxConcurrency": 50,
    "Iterator": {
      "StartAt": "ChargeCustomer",
      "States": {
        "ChargeCustomer": {
          "Type": "Task",
          "Resource": "arn:aws:lambda:us-east-1:123456789012:function:charge-customer",
          "End": true
        }
      }
    },
    "Next": "RecordBillingRun"
  }
}

Failure mode 3: concurrent StartExecution calls launch two Standard Workflow executions for the same billing period

EventBridge (CloudWatch Events) delivers events to Step Functions with at-least-once semantics. A monthly billing EventBridge rule that invokes states:StartExecution can, under high throughput or internal retries, deliver the same scheduled event twice. Each delivery starts a new Standard Workflow execution with a system-generated execution name. Both executions run concurrently, each independently proceeding through the IssueVaultKeys and BillingFanOut states. A second path for double-start: a developer manually invokes aws stepfunctions start-execution while the scheduled run is still active, intending to process a single missed customer — but inadvertently starting a full second billing run for the same period. Both executions reach the Map state, fetch the same customer list, issue separate vault keys per customer, and call stripe.Charge.create() for the same customers. With content-hash idempotency keys, Stripe returns the original charge for each customer — no double charge at the payment layer. But both executions log "charged" in DynamoDB, creating confusing duplicate billing run records and incorrect business metrics that surface in reconciliation.

Step Functions Standard Workflows allow a unique name parameter on StartExecution. Two StartExecution calls with the same name within 90 days cause the second call to return the ARN of the already-running execution rather than launching a new one. Use a deterministic execution name derived from the billing period as the dedup key. Configure the EventBridge target to pass the billing period in the execution name via an input transformer, and enforce it in any manual invocation convention.

# EventBridge target configuration — SAFE: deterministic execution name per billing period
# In the EventBridge rule "Targets" section, set the input transformer and execution name:
{
  "Targets": [
    {
      "Id": "MonthlyBillingTarget",
      "Arn": "arn:aws:states:us-east-1:123456789012:stateMachine:MonthlyBillingWorkflow",
      "RoleArn": "arn:aws:iam::123456789012:role/EventBridgeStepFunctionsRole",
      "InputTransformer": {
        "InputPathsMap": {
          "time": "$.time"
        },
        "InputTemplate": "{\"billingPeriod\": <time>}"
      }
    }
  ]
}

# In the Lambda that wraps StartExecution (or via SDK directly):
import boto3
import re

def start_billing_execution(billing_period: str) -> str:
    # billing_period = "2026-07-01T03:00:00Z" → execution name = "billing-2026-07"
    period_slug = re.sub(r"[^0-9-]", "", billing_period[:7])  # "2026-07"
    execution_name = f"billing-{period_slug}"

    sfn = boto3.client("stepfunctions")
    try:
        resp = sfn.start_execution(
            stateMachineArn=os.environ["STATE_MACHINE_ARN"],
            name=execution_name,  # Deterministic: second call returns existing ARN
            input=json.dumps({"billingPeriod": billing_period, "customers": []}),
        )
        return resp["executionArn"]
    except sfn.exceptions.ExecutionAlreadyExists:
        # The scheduled run is already active — this is safe to ignore
        return "already-running"

Add a pre-flight check as the first state in the workflow: a Lambda that queries DynamoDB for a completed billing run record for the current period. If the record exists, the workflow transitions to a Success state immediately and makes no Stripe calls. This application-level guard protects against infrastructure anomalies that could bypass the execution-name dedup — for example, an EventBridge rule firing twice within the 90-day execution name window with the same name resolves correctly, but a name collision from a different billing period or a manual execution with an incorrect name would still reach the billing states. The pre-flight check is defense-in-depth against all concurrent-start scenarios.

# preflight_check.py — first Lambda in the state machine
import boto3
import os

def handler(event, context):
    billing_period = event["billingPeriod"]
    dynamodb = boto3.resource("dynamodb")
    table = dynamodb.Table(os.environ["BILLING_RUNS_TABLE"])

    existing = table.get_item(Key={"billingPeriod": billing_period}).get("Item")
    if existing and existing.get("status") == "completed":
        return {**event, "alreadyBilled": True, "billingPeriod": billing_period}

    return {**event, "alreadyBilled": False}
# State machine — SAFE: preflight + deterministic execution name + idempotency keys
{
  "StartAt": "PreflightCheck",
  "States": {
    "PreflightCheck": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:preflight-check",
      "Next": "AlreadyBilled?"
    },
    "AlreadyBilled?": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.alreadyBilled",
          "BooleanEquals": true,
          "Next": "SkipBilling"
        }
      ],
      "Default": "IssueVaultKeys"
    },
    "SkipBilling": {
      "Type": "Succeed"
    },
    "IssueVaultKeys": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:issue-vault-keys",
      "Next": "BillingFanOut"
    },
    "BillingFanOut": {
      "Type": "Map",
      "ItemsPath": "$.customers",
      "MaxConcurrency": 50,
      "Iterator": {
        "StartAt": "ChargeCustomer",
        "States": {
          "ChargeCustomer": {
            "Type": "Task",
            "Resource": "arn:aws:lambda:us-east-1:123456789012:function:charge-customer",
            "End": true
          }
        }
      },
      "Next": "MarkCompleted"
    },
    "MarkCompleted": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:mark-completed",
      "End": true
    }
  }
}

Approach comparison

Approach Retry safe? Fan-out cap? Concurrent start safe? Per-item audit? Mid-run revoke?
Bare key, no idempotency key No — every retry is a new charge No No No No
Content-hash idempotency key only Yes (within 24h Stripe window) No Partial — Stripe deduplicates but audit log doubles No No
Deterministic execution name only No No Yes (within 90-day SFN window) No No
Stripe restricted key (no proxy) No No — endpoint scope only, no dollar cap No No No
Content-hash idem key + exec name + preflight + vault key via Keybrake Yes Yes — per-item dollar cap Yes Yes Yes — single DELETE /vault/keys/{id}

pytest enforcement suite

# test_sfn_billing.py
import hashlib
import pytest

def billing_idempotency_key(customer_id: str, amount_cents: int, billing_period: str) -> str:
    raw = f"{customer_id}:{amount_cents}:{billing_period}:sfn-billing"
    return hashlib.sha256(raw.encode()).hexdigest()[:32]

def test_idempotency_key_stable_across_retries():
    key1 = billing_idempotency_key("cus_abc", 4999, "2026-07")
    key2 = billing_idempotency_key("cus_abc", 4999, "2026-07")
    assert key1 == key2
    assert len(key1) == 32

def test_idempotency_key_differs_across_periods():
    key_jun = billing_idempotency_key("cus_abc", 4999, "2026-06")
    key_jul = billing_idempotency_key("cus_abc", 4999, "2026-07")
    assert key_jun != key_jul

def test_idempotency_key_differs_across_customers():
    key_a = billing_idempotency_key("cus_abc", 4999, "2026-07")
    key_b = billing_idempotency_key("cus_xyz", 4999, "2026-07")
    assert key_a != key_b

def test_execution_name_is_deterministic(monkeypatch):
    import re
    billing_period = "2026-07-01T03:00:00Z"
    period_slug = re.sub(r"[^0-9-]", "", billing_period[:7])
    execution_name = f"billing-{period_slug}"
    # Two calls with same billing period must produce identical execution name
    assert execution_name == "billing-2026-07"

def test_vault_key_sourced_from_event_not_env(monkeypatch):
    # charge_customer Lambda must use event["vaultKey"], not os.environ["STRIPE_SECRET_KEY"]
    import os
    monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_live_real_secret")
    event = {"customerId": "cus_abc", "amountCents": 4999, "billingPeriod": "2026-07",
             "vaultKey": "kb_vault_test_abc123"}
    # Vault key must start with "kb_", not "sk_live_"
    assert event["vaultKey"].startswith("kb_")
    assert event["vaultKey"] != os.environ["STRIPE_SECRET_KEY"]

Gap analysis

1. Express Workflow at-least-once execution semantics bypass deterministic execution naming

Standard Workflows enforce the 90-day uniqueness constraint on execution names and provide exactly-once execution semantics at the Step Functions layer (though Lambda itself is still at-least-once). Express Workflows do not support named execution deduplication and have at-least-once execution semantics: a single StartExecution call can result in the workflow running more than once under failure conditions. For billing workflows, always use Standard Workflows. If Express Workflows are required for cost reasons on high-volume pipelines, add application-level deduplication at every state that calls Stripe — a DynamoDB conditional write (attribute_not_exists(chargeId)) before each charge call, not just a pre-flight check at workflow start.

2. Map state ToleratedFailureCount silently continues after per-item cap hits

Step Functions' Map state supports ToleratedFailureCount and ToleratedFailurePercentage — the Map can complete "successfully" even if some iterations fail, up to the tolerated threshold. If SpendCapExhausted errors are treated as tolerated failures, the parent workflow proceeds to RecordBillingRun and marks the period as completed, even though some customers were not charged (their cap was exhausted). Set ToleratedFailureCount: 0 for billing Map states, or handle cap-exhausted iterations in a separate alerting path and treat them as non-tolerable failures that halt the workflow.

3. ItemBatcher grouping causes one vault key to cover multiple customers

Step Functions Map supports ItemBatcher to group input items into batches before dispatching to the Lambda iterator. If batching is enabled, one Lambda invocation receives multiple customer records. A single vault key issued for "one customer's expected charge" is insufficient — the Lambda will make multiple Stripe calls, and the cap must be sized to cover all customers in the batch. When using ItemBatcher, sum the expected charge amounts across all items in each batch before issuing the vault key, or disable batching for billing Map states and use MaxConcurrency alone for throughput control.

4. Activity tasks and worker-side heartbeat failures create a different retry surface

Some Step Functions architectures use Activity tasks rather than Lambda — a long-running worker polls GetActivityTask, processes the work, and calls SendTaskSuccess or SendTaskFailure. If the worker crashes or loses the network connection after calling stripe.Charge.create() but before calling SendTaskSuccess, Step Functions' heartbeat timeout fires and marks the task as failed. A new worker picks up the task and calls stripe.Charge.create() again. Content-hash idempotency keys protect against this exactly as they do for Lambda retries — the key is computed from the task payload, which is identical on every worker pick-up, so Stripe returns the original charge. Workers using Activity tasks must implement the same idempotency key pattern as Lambda handlers.

Put the brakes on your Step Functions billing workflows

Keybrake issues per-item vault keys for your Map fan-outs, enforces dollar caps atomically before any Lambda invocation calls Stripe, and logs every charge with Step Functions execution context. One proxy endpoint swap — no state machine restructuring required.

Frequently asked questions

Does the 90-day execution name uniqueness window mean I need a different dedup strategy for old billing periods?

Standard Workflows reject a StartExecution call with an already-used name only for executions started within the past 90 days. If a billing pipeline is re-run for a period older than 90 days (unusual, but possible during reconciliation), the execution name dedup will not catch the duplicate — a second execution will launch. The pre-flight DynamoDB check is the correct guard for this case: it queries by billingPeriod regardless of when the original execution ran. Always implement both defenses rather than relying on the 90-day window alone.

How should vault key TTL be set for Map fan-outs with large customer cohorts?

Vault key TTL must cover the time from issuance in the IssueVaultKeys Lambda to the last Lambda invocation in the Map fan-out completing its Stripe call. With MaxConcurrency: 50 and 500 customers, Step Functions processes 10 batches of 50 in sequence (the Map pauses each batch until all 50 complete). If each Lambda invocation takes up to 10 seconds (including Stripe API latency), 10 batches take up to 100 seconds under ideal conditions — but Lambda cold starts, DynamoDB write contention, and retry backoff can extend this to 10–15 minutes. Set vault key TTL to 30 minutes for cohorts up to 1,000 customers, or implement a re-issuance pattern for larger cohorts: issue keys in smaller batches within the Map iterator, fetching a fresh key at invocation start rather than pre-issuing all keys upfront.

Can IAM policy conditions on the Lambda execution role replace vault keys?

IAM policies control which AWS actions the Lambda role can call — they cannot restrict the Stripe API calls the Lambda code makes. A Lambda with STRIPE_SECRET_KEY in its environment is authorized by Stripe, not by IAM, to make arbitrary charges up to whatever Stripe allows. IAM cannot set a dollar cap on Stripe calls, cannot restrict which Stripe endpoints are callable, and cannot revoke mid-execution. Vault keys operate at the Stripe API boundary, not the AWS IAM boundary — the two systems govern different layers and are complementary.

What is the difference between using Retry with filtered ErrorEquals versus a Catch block for Stripe errors?

Retry re-invokes the Lambda — appropriate for transient errors where retrying with the same idempotency key is safe (DynamoDB timeouts, Lambda service exceptions). Catch transitions the execution to a different state — appropriate for permanent errors where retrying will never succeed (card declined, invalid customer, spend cap exhausted). Configure Retry to match only AWS and Lambda infrastructure errors, and Catch to match your application-level permanent error classes. Without this split, a card decline error would consume all three MaxAttempts retries, making three Stripe API calls that all return the same error, before the execution moves to the failure state.

How does the proxy handle the case where the Keybrake proxy is unreachable during a Map fan-out?

If the Keybrake proxy is unreachable, the Stripe SDK in the Lambda throws a network error (connection refused, timeout). Step Functions' Retry block catches the Lambda exception and re-invokes with the same idempotency key. No Stripe charges are made while the proxy is down — the Stripe API is never reached. Monitor proxy health via its /health endpoint from within your VPC using a Route 53 health check or a CloudWatch Synthetics canary. If the proxy goes down during a large Map fan-out, Step Functions will exhaust the retry budget across all Map iterations and the execution will fail — triggering your normal Step Functions failure alerting.

Can I use Step Functions' ResultSelector and ResultPath to strip vault keys from execution history?

Yes — Step Functions logs all task input and output to CloudWatch Logs and optionally to the execution event history visible in the AWS console. Vault keys passed as Lambda event parameters appear in this history. Use ResultSelector in the IssueVaultKeys state to return only metadata (customer IDs, cap amounts) rather than the vault keys themselves, and store the vault keys in AWS Secrets Manager or Parameter Store. Have each charge Lambda fetch its vault key by ID from Secrets Manager at invocation start rather than receiving the key value in the event payload. This keeps vault key values out of Step Functions' execution history, CloudWatch Logs exports, and S3 execution log exports.

Further reading