Small e-commerce businesses adopt AI agents to handle payments and fulfillment, but a hallucinated or misrouted Stripe call can issue refunds, alter subscriptions, or expose cardholder data — and they lack the tooling to audit and block those actions in real time.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This tutorial builds an MCP gateway that intercepts every Stripe tool call from an AI agent, evaluates it against multiple guardrail layers (PII redaction, prompt injection detection, topic boundaries, HAI security guards), runs a Bedrock-powered policy evaluation using Claude Sonnet, and then executes approved calls through the Stripe SDK with circuit-breaker and idempotency protection. You’ll end up with a Next.js API that an AI agent can call instead of talking to Stripe directly — every operation is inspected, rate-limited, authenticated, and auditable before it reaches Stripe.
This tutorial is for TypeScript developers who want to secure AI agent payment flows. You should be familiar with Node.js, Express middleware concepts, and Stripe basics.
Prerequisites
Node.js 22+ and pnpm 10
AWS account with Bedrock access enabled in us-east-1 (or your preferred region)
Stripe account with API keys (test mode is fine)
An AWS access key pair with bedrock:InvokeModel permission for Claude Sonnet
(Optional) Langfuse account for LLM observability tracing
Step 1: Create the project and install dependencies
Scaffold a new Next.js project with TypeScript and the App Router, then install all the packages this recipe needs. Every dependency is pinned to an exact version so the behavior is reproducible.
cd aws-bedrock-security-guardrails-for-stripe-payment-agents
Add the dependencies. The guardrail, auth, rate-limit, circuit-breaker, and idempotency packages come from the @reaatech ecosystem. The AWS SDK and HAI guardrails handle Bedrock policy evaluation and security scanning.
Expected output:pnpm install completes without errors. The dependencies and devDependencies in package.json show every package with a pinned version.
Step 2: Configure environment variables
Create the environment variable template first, then copy it into your working .env file.
terminal
cat > .env.example << 'EOF'# AWS Bedrock configurationAWS_REGION=us-east-1AWS_ACCESS_KEY_ID=<your-access-key>AWS_SECRET_ACCESS_KEY=<your-secret># Stripe APISTRIPE_SECRET_KEY=<your-stripe-secret-key>STRIPE_API_VERSION=<stripe-api-version> # defaults to 2025-02-24.acacia if not set# Langfuse observability (optional)LANGFUSE_SECRET_KEY=<your-langfuse-secret>LANGFUSE_PUBLIC_KEY=<your-langfuse-public>LANGFUSE_BASE_URL=<your-langfuse-host># Human approval webhook (optional)HUMAN_APPROVAL_WEBHOOK_URL=<webhook-url>EOF
Now copy it and fill in your real credentials:
terminal
cp .env.example .env
Open .env and set your values for AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, STRIPE_SECRET_KEY, and the optional Langfuse and webhook variables. The STRIPE_API_VERSION falls back to 2025-02-24.acacia if you leave the placeholder unchanged.
Expected output: A .env file with your credentials filled in.
Step 3: Create shared types and constants
Create src/types.ts — this defines the data shapes that flow through the entire system.
Expected output: Two files — src/types.ts with all 7 interfaces and src/constants.ts with default policy values. Run pnpm typecheck and you should see no errors.
Step 4: Build the Stripe client
Create src/api/stripe.ts — this wraps the Stripe SDK and exposes typed operations plus a result-sanitization utility.
ts
import Stripe from "stripe";import type { SanitizedStripeResult } from "../types.js";export class StripeClient { private stripe: Stripe; constructor(apiKey: string) { this.stripe = new Stripe(apiKey); } async charge(params: { amount: number; currency: string; source: string; description?: string; }): Promise
Expected output: File created at src/api/stripe.ts. pnpm typecheck passes.
Step 5: Implement policy definitions and validation
Create src/guardrails/policies.ts — the local business rules that check operation allowlists, refund thresholds, and PII presence.
ts
import type { PolicyConfig } from "../types.js";import { DEFAULT_POLICY_CONFIG } from "../constants.js";export function loadPolicyConfig( overrides?: Partial<PolicyConfig>,): PolicyConfig { return { ...DEFAULT_POLICY_CONFIG, ...overrides };}export function validateOperationAllowed( operation: string, config: PolicyConfig,): { allowed: boolean; reason?: string } { if (config.blockedOperations.includes(operation)) { return { allowed: false, reason: `Operation '${operation}' is blocked by policy`, }; } if (config.requiredApprovalOperations.includes(operation)) { return { allowed: true, reason: `Operation '${operation}' requires human approval`, }; } return { allowed: true };}export function validateRefundAmount( amount: number, config: PolicyConfig,): { allowed: boolean; reason?: string } { if (amount > config.maxRefundAmount) { return { allowed: false, reason: `Refund amount $${String(amount)} exceeds max of $${String(config.maxRefundAmount)}`, }; } return { allowed: true };}export function validatePiiInParams( params: Record<string, unknown>, config: PolicyConfig,): { containsPii: boolean; redactedParams: Record<string, unknown> } { const redactedParams: Record<string, unknown> = {}; let containsPii = false; for (const [key, value] of Object.entries(params)) { if (config.piiFields.includes(key)) { containsPii = true; redactedParams[key] = "[REDACTED]"; } else { redactedParams[key] = value; } } return { containsPii, redactedParams };}
Expected output:src/guardrails/policies.ts with four exported functions. pnpm typecheck passes.
Step 6: Build the Bedrock-powered policy evaluator
Create src/guardrails/policy-evaluator.ts — this sends the tool call to Amazon Bedrock’s Converse API (Claude Sonnet) and asks the model to decide if the operation is safe.
ts
import { BedrockRuntimeClient, ConverseCommand,} from "@aws-sdk/client-bedrock-runtime";import type { StripeToolCall, PolicyDecision, PolicyConfig } from "../types.js";import { BEDROCK_MODEL_ID, DEFAULT_POLICY_CONFIG } from "../constants.js";export class PolicyEvaluator { private bedrock: BedrockRuntimeClient; private config: PolicyConfig; constructor( bedrock: BedrockRuntimeClient, config?: PolicyConfig, ) { this.bedrock = bedrock; this.config = config ??
Expected output:src/guardrails/policy-evaluator.ts. The evaluator formats a prompt describing the operation and policy rules, calls Bedrock’s Converse API, and parses the JSON response. If Bedrock is unreachable or the response is unparseable, it falls back to a blocked decision with severity "high".
Step 7: Wire up the guardrail chain and HAI guards
Create src/guardrails/guardrail-chain.ts — a pipeline of input guardrails using the @reaatech/guardrail-chain framework.
Your guardrail chain now runs four checks in sequence: PII redaction (email, phone, SSN masking), prompt injection detection (cached for 5 minutes), topic boundary enforcement (only payment-related topics allowed), and cost precheck. The HAI engine adds three more: heuristic injection detection, PII scanning, and secret/key detection.
Expected output: Both files created. pnpm typecheck passes with no errors.
Step 8: Add authentication and rate limiting
Create src/gateway/auth.ts — wraps the @reaatech/mcp-gateway-auth package.
Create src/gateway/rate-limit.ts — configures the token-bucket rate limiter from @reaatech/mcp-gateway-rate-limit.
ts
import { NextResponse } from "next/server.js";import { createRateLimiter, type RateLimitResult,} from "@reaatech/mcp-gateway-rate-limit";import type { AuthContext } from "@reaatech/mcp-gateway-auth";import { RATE_LIMIT_DEFAULTS } from "../constants.js";import { extractTenantId } from "./auth.js";export const rateLimiter = createRateLimiter({ storeType: "memory", defaultConfig: { requestsPerMinute: RATE_LIMIT_DEFAULTS.requestsPerMinute, requestsPerDay: RATE_LIMIT_DEFAULTS.requestsPerDay, burstSize: RATE_LIMIT_DEFAULTS.burstSize, },});export async function checkRateLimit( authContext: AuthContext,): Promise<RateLimitResult> { const key = extractTenantId(authContext); return rateLimiter.checkLimit(key, { requestsPerMinute: RATE_LIMIT_DEFAULTS.requestsPerMinute, requestsPerDay: RATE_LIMIT_DEFAULTS.requestsPerDay, burstSize: RATE_LIMIT_DEFAULTS.burstSize, });}export function buildRateLimitErrorResponse( result: RateLimitResult,): NextResponse { return NextResponse.json( { error: "Rate limit exceeded", retryAfter: result.retryAfter, }, { status: 429, headers: { "Retry-After": String(result.retryAfter), }, }, );}
Expected output: Both files created. The rate limiter uses an in-memory store keyed by tenant ID, with 100 requests per minute and a burst capacity of 50.
Step 9: Create the gateway orchestrator
Create src/gateway/index.ts — the core class that chains everything together: rate limit check, guardrail chain, HAI guards, local policy validation, Bedrock policy evaluation, and finally Stripe execution with circuit breaker and idempotency.
ts
import type { GatewayRequest, GatewayResponse, PolicyConfig } from "../types.js";import { StripeClient } from "../api/stripe.js";import { PolicyEvaluator } from "../guardrails/policy-evaluator.js";import { createGuardrailChain } from "../guardrails/guardrail-chain.js";import { createHaiGuardrailsEngine, runHaiGuardrails,} from "../guardrails/hai-guards.js";import { loadPolicyConfig, validateOperationAllowed, validateRefundAmount,} from "../guardrails/policies.js";import { checkRateLimit } from "./rate-limit.js";import { executeWithCircuitBreaker } from "../services/circuit-breaker.js";import
Expected output:src/gateway/index.ts. The orchestrator runs all six guard stages in order — rate limit, guardrail chain, HAI guards, local policy checks, Bedrock evaluation, and Stripe execution with idempotency and circuit breaker. If any stage rejects the request, it short-circuits and returns an error immediately.
Step 10: Add circuit breaker and idempotency services
Create src/services/circuit-breaker.ts — wraps Stripe operations so that after 5 consecutive failures, the circuit opens and blocks further calls for 30 seconds.
ts
import { CircuitBreaker, CircuitOpenError, InMemoryAdapter,} from "@reaatech/circuit-breaker-agents";import { CIRCUIT_BREAKER_DEFAULTS } from "../constants.js";export class CircuitBreakerOpenError extends Error { readonly code = "CIRCUIT_OPEN"; constructor(message: string) { super(message); this.name = "CircuitBreakerOpenError"; }}const _breaker = new CircuitBreaker({ name: "stripe-operations", failureThreshold: CIRCUIT_BREAKER_DEFAULTS.failureThreshold, recoveryTimeoutMs: CIRCUIT_BREAKER_DEFAULTS.recoveryTimeoutMs, persistence: new InMemoryAdapter(), metricsEnabled: true,});export function getStripeCircuitBreaker(): CircuitBreaker { return _breaker;}export async function executeWithCircuitBreaker<T>( operation: () => Promise<T>,): Promise<T> { try { return await _breaker.execute(operation); } catch (error) { if (error instanceof CircuitOpenError) { throw new CircuitBreakerOpenError( "Stripe operations temporarily unavailable", ); } throw error; }}
Create src/services/idempotency.ts — prevents duplicate Stripe calls by caching results keyed by an idempotency key.
ts
import { IdempotencyMiddleware, MemoryAdapter, IdempotencyError, IdempotencyErrorCode,} from "@reaatech/idempotency-middleware";import { IDEMPOTENCY_TTL } from "../constants.js";let adapter: MemoryAdapter | null = null;let middleware: IdempotencyMiddleware | null = null;async function ensureInitialized(): Promise<IdempotencyMiddleware> { if (middleware !== null) { return middleware; } adapter = new MemoryAdapter(); await adapter.connect(); middleware = new IdempotencyMiddleware(adapter, { ttl: IDEMPOTENCY_TTL, lockTimeout: 30000, includeBodyInKey: true, }); return middleware;}export async function executeWithIdempotency<T>( key: string, context: { method: string; path: string; body: unknown; }, handler: () => Promise<T>,): Promise<T> { const mw = await ensureInitialized(); try { return await mw.execute(key, context, handler); } catch (error) { if (error instanceof IdempotencyError) { switch (error.code) { case IdempotencyErrorCode.KEY_REQUIRED: { throw Object.assign( new Error("Idempotency key is required"), { statusCode: 400 }, ); } case IdempotencyErrorCode.LOCK_TIMEOUT: case IdempotencyErrorCode.CONFLICT: { throw Object.assign( new Error("Idempotency conflict"), { statusCode: 409 }, ); } case IdempotencyErrorCode.STORAGE_ERROR: { throw Object.assign( new Error("Idempotency storage error"), { statusCode: 503 }, ); } default: { throw error; } } } throw error; }}
Expected output: Both files created. The circuit breaker opens after 5 failures and auto-recovers after 30s. The idempotency middleware caches results for 24 hours.
Step 11: Create the Next.js API route handler
Create app/api/gateway/route.ts — this is the main entry point an AI agent calls.
Expected output: Three files — health, webhook route, and webhook handler. The health endpoint returns { status: "ok" }. The webhook route accepts approval decisions and runs the approved Stripe operation.
Step 13: Add optional Langfuse observability
Create src/observability/langfuse.ts — trace creation for LLM observability:
Expected output:src/observability/langfuse.ts. When LANGFUSE_SECRET_KEY is set, it creates Langfuse traces. Without the key, all functions are no-ops — no observability overhead when you don’t need it.
Step 14: Run the tests
The recipe includes a test suite that exercises every module. Run it to verify everything works:
terminal
pnpm test
Expected output: All 95 tests pass across 16 test files with at least 90% line, branch, function, and statement coverage. The runner output looks like:
Expected output:pnpm typecheck exits cleanly with no errors. pnpm lint passes all ESLint rules.
Next steps
Swap to Redis-backed rate limiting — replace the in-memory rate limiter’s storeType: "memory" with "redis" and pass a Redis client. This lets you share rate limit state across multiple server instances.
Wire Langfuse tracing into the orchestrator — call createTrace() at the start of processRequest() and wrap each guardrail stage with observe() so you can see per-request guardrail latency in Langfuse dashboards.
Extend HAI guards with toxicity and bias detection — add toxicGuard and biasGuard from @presidio-dev/hai-guardrails to screen Stripe parameters for harmful content before they reach the payment API.
<
Stripe
.
Charge
> {
return this.stripe.charges.create(params);
}
async refund(params: {
chargeId: string;
amount?: number;
reason?: string;
}): Promise<Stripe.Refund> {
return this.stripe.refunds.create({
charge: params.chargeId,
amount: params.amount,
reason: params.reason as Stripe.RefundCreateParams.Reason,