Skip to content

· ·

Bedrock AgentCore + x402: An AI Agent That Pays for Its Own API Calls

I built a Strands agent running on AWS Bedrock AgentCore Runtime that pays for paywalled content with real USDC on Base Sepolia, and the model never sees the payment step. When the agent's HTTP tool gets back a 402 Payment Required, a plugin signs an EIP-3009 USDC authorization through a Coinbase wallet, retries the call, and hands the model the response as if nothing happened. The full source is in wgopar/bedrock-agentcore-x402.

This post walks through the project from zero. We'll start with what Bedrock AgentCore actually is (because it's a new AWS service and the documentation assumes a lot), then bring in x402, then dig into the code that makes a payment invisible to an LLM. By the end you should be able to clone the repo and reproduce the on-chain settlement yourself: here's the basescan tx from one of my own runs.


What is AWS Bedrock AgentCore?

If you haven't touched it yet: AgentCore is AWS's hosted runtime for AI agents. It launched in preview in 2025 and is best thought of as "Lambda, but for long-running agent loops." You containerize your agent code, push the image to ECR, and AgentCore Runtime runs it for you, handling the session lifecycle, the HTTP frontend, and the IAM-secured invocation endpoint.

A normal AWS Lambda is a poor fit for an agent. Lambdas are stateless, short-lived, and have a 15-minute hard ceiling. An agent reasoning loop (call a tool, look at the result, decide what to do next, call another tool) often wants persistent session state, longer execution windows, and bidirectional streaming back to the caller. AgentCore Runtime fills that gap. From your perspective, it looks like this:

graph LR
    Caller["Caller<br/>(your code)"] -->|"invoke_agent_runtime"| API["AgentCore Runtime API"]
    API --> Session["Session<br/>(per runtimeSessionId)"]
    Session --> Container["Your container<br/>(agent code on port 8080)"]
    Container -->|"streaming response"| API
    API --> Caller
          

The container exposes two endpoints (/ping for health checks and /invocations for the handler), and you don't have to write either of them. AWS ships an SDK (bedrock-agentcore) with a tiny app object that registers both for you:

from bedrock_agentcore.runtime import BedrockAgentCoreApp

app = BedrockAgentCoreApp()

@app.entrypoint
def invoke(payload: dict) -> dict:
    prompt = payload.get("prompt", "")
    return {"response": f"got: {prompt}"}

if __name__ == "__main__":
    app.run()

That's the whole contract. The decorator wires your function to /invocations; app.run() boots an ASGI server on port 8080; the Dockerfile copies this file into an arm64 Python image and you're done. AgentCore handles the rest: TLS, auth, session routing, scaling, logging to CloudWatch.

"AgentCore" is actually an umbrella name for a few related preview services. The two we care about here:

  • AgentCore Runtime: the hosted container runtime described above.
  • AgentCore Payments: a managed signer that holds delegated credentials for a Coinbase Developer Platform (CDP) wallet and can produce x402-shaped signatures on demand, so your agent code never touches a private key.

We'll come back to Payments after introducing x402, because the two only really make sense together.


What is x402?

x402 is a payment protocol built on a status code that's been sitting unused in the HTTP spec since 1999: 402 Payment Required. The idea is simple. If an API costs money, it returns 402 with a machine-readable description of what payment it wants, the client signs a gasless USDC authorization, and retries with a header. No API keys, no Stripe redirect, no subscription dashboard. I've written about the protocol from a few different angles in x402-cli, the agent template post, and the health-check agent, but here's the minimal version you need for this project.

An x402-protected endpoint returns this when you call it without paying:

$ curl -i https://dqtf3wyiruv25.cloudfront.net/premium/data.json

HTTP/2 402
content-type: application/json
payment-required: eyJ4NDAyVmVyc2lvbiI6MiwiYWNjZXB0cyI6W3sic2NoZW1lIjoi...

{"error":"Payment Required","x402Version":2}

The PAYMENT-REQUIRED header is base64-encoded JSON describing what's owed: which network (Base Sepolia, in our case), which token (USDC), the recipient address, the amount in atomic units, and an EIP-712 domain so the client knows what to sign. To get the actual content, you sign an EIP-3009 transferWithAuthorization (a USDC primitive that lets a third party submit your transfer on-chain) and resend the request with the signature in a PAYMENT-SIGNATURE header. The server hands the signature to a facilitator, which verifies it off-chain and broadcasts the on-chain transfer. If that settles, the server returns the content.

The payoff: an API can charge a fraction of a cent per call without ever issuing credentials. Which is exactly the property an autonomous AI agent needs to consume paid resources without a human in the loop.


Putting them together

Here's the full round-trip we're building. Read the diagram once before the code. It's the mental model the rest of the post hangs off of.

sequenceDiagram
    actor Caller
    participant Runtime as AgentCore Runtime
    participant Agent as Strands Agent
    participant Plugin as Payments Plugin
    participant Merchant as Lambda@Edge
(CloudFront) participant Payments as AgentCore Payments participant CDP as Coinbase CDP participant Facilitator as x402.org
facilitator participant Chain as Base Sepolia participant S3 Caller->>Runtime: invoke {prompt, session_id} Runtime->>Agent: run Agent->>Merchant: GET /premium/data.json Merchant-->>Agent: 402 PAYMENT-REQUIRED Note over Agent,Plugin: Plugin's after_tool_call hook
intercepts the 402 Plugin->>Payments: ProcessPayment Payments->>CDP: sign EIP-3009 CDP-->>Payments: signature Payments-->>Plugin: PAYMENT-SIGNATURE Plugin->>Merchant: GET /premium/data.json + PAYMENT-SIGNATURE Merchant->>Facilitator: POST /verify Facilitator-->>Merchant: ok Merchant->>Facilitator: POST /settle Facilitator->>Chain: transferWithAuthorization (USDC) Chain-->>Facilitator: tx confirmed Facilitator-->>Merchant: settled Merchant->>S3: GET premium/data.json S3-->>Merchant: JSON Merchant-->>Agent: 200 + JSON Agent-->>Caller: response

The big idea: the model only ever sees steps "GET" and "200 + JSON". Everything between those two (the 402, the signing, the facilitator round-trip, the on-chain settlement) happens inside the Payments Plugin's after_tool_call hook, transparently to the LLM. That's the bit I find architecturally interesting, so let's look at the code that pulls it off.


The agent code

The agent itself is a Strands agent. Strands is a small Python framework for tool-calling LLMs. You decorate functions with @tool, hand them to an Agent, and Strands handles the loop of "model picks a tool → run it → feed result back." Here's the relevant slice of agent/main.py:

from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig
from bedrock_agentcore.payments.integrations.strands import AgentCorePaymentsPlugin
from bedrock_agentcore.runtime import BedrockAgentCoreApp
from strands import Agent
from strands.models.bedrock import BedrockModel
from strands_tools import http_request

model = BedrockModel(model_id="us.amazon.nova-micro-v1:0", region_name="us-west-2")

def _build_agent(payment_session_id: str | None) -> Agent:
    plugins = [
        AgentCorePaymentsPlugin(AgentCorePaymentsPluginConfig(
            payment_manager_arn=PAYMENT_MANAGER_ARN,
            user_id=USER_ID,
            payment_instrument_id=PAYMENT_INSTRUMENT_ID,
            payment_session_id=payment_session_id,
            region=REGION,
        ))
    ]
    return Agent(
        model=model,
        tools=[http_request],
        plugins=plugins,
        system_prompt=(
            "You are a helpful assistant. "
            "To fetch data from a URL, use the http_request tool with method GET. "
            "If the response is HTTP 402 Payment Required, the framework handles "
            "the payment transparently — just make the request again if asked and "
            "return whatever JSON content you receive."
        ),
    )

app = BedrockAgentCoreApp()

@app.entrypoint
def invoke(payload: dict) -> dict:
    prompt = payload.get("prompt", "")
    payment_session_id = payload.get("payment_session_id")
    agent = _build_agent(payment_session_id=payment_session_id)
    result = agent(prompt)
    return {"response": str(result)}

Three things to notice here:

  1. The model is Nova Micro, the cheapest Bedrock model. This works because the model never has to reason about the payment; it only has to decide "user asked for a URL, I should call http_request." A bigger model is wasted spend.
  2. The only tool is http_request from strands_tools. The agent has no pay tool, no sign tool, no awareness that payments exist.
  3. The plugin is the magic. AgentCorePaymentsPlugin registers an after_tool_call hook with Strands. When http_request returns a response, the plugin gets first look at it. If it's a 402, the plugin re-runs the request with a signed header before Strands ever shows the result to the LLM.

The system prompt is almost vestigial. "If you get a 402, just make the request again." In practice the model doesn't even see the 402, because the plugin substitutes the second (paid) response in place of the first. The prompt is defensive coding for the case where, say, the session expires mid-call and the plugin can't sign.


The merchant side

Now let's look at the other end of the conversation: the merchant. The merchant is the thing that issues the 402 challenge, accepts the signed retry, talks to the facilitator to verify and settle on-chain, and only then serves the actual content. We saw it as a single box in the big sequence diagram earlier. Here it is taken apart.

I built the merchant out of three AWS pieces stacked together:

  • S3 holds the file the agent is paying for. S3 is AWS's object storage, basically a bucket of files with URLs.
  • CloudFront is AWS's CDN, sitting in front of S3. The bucket itself stays private; CloudFront is the only thing allowed to read from it. This is the canonical AWS pattern for serving a private bucket publicly through one controlled door.
  • Lambda@Edge is a small Python function CloudFront runs on every request before it would otherwise go to S3. This is where the 402 logic lives. A normal Lambda runs in one AWS region; Lambda@Edge runs at every CloudFront edge point of presence, so the payment check is fast and geographically close to the caller.

In practice: if the Lambda@Edge function sees an unpaid request, it short-circuits with a 402 response and S3 is never touched. If it sees a request carrying a valid PAYMENT-SIGNATURE, it asks the facilitator to verify and settle, and only on success does it let CloudFront proceed to fetch the file from S3.

The constraint that shaped this code: Lambda@Edge has no environment variables, no AWS SDK calls outside the standard library, and a 1 MB zip limit. So the handler is stdlib only, with constants hardcoded in the file. Here's the heart of it:

PAY_TO_ADDRESS = "0x000000000000000000000000000000000000dEaD"
PRICE_ATOMIC_USDC = "1000"          # 0.001 USDC (USDC has 6 decimals)
NETWORK = "eip155:84532"            # Base Sepolia
USDC_ASSET = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
PAYWALL_PREFIX = "/premium/"
FACILITATOR_BASE = "https://www.x402.org/facilitator"

def handler(event, context):
    request = event["Records"][0]["cf"]["request"]
    uri = request["uri"]

    if not uri.startswith(PAYWALL_PREFIX):
        return request  # not paywalled, let it through

    host = _header(request["headers"], "host") or "merchant"
    resource_url = f"https://{host}{uri}"

    signature_b64 = _header(request["headers"], "payment-signature")
    if not signature_b64:
        return _make_402(resource_url)  # first call: challenge

    payment_payload = json.loads(base64.b64decode(signature_b64))

    ok, err = _verify_and_settle(payment_payload, _requirements(resource_url))
    if not ok:
        return _make_402(resource_url, error=err)

    return request  # payment confirmed → passthrough to S3

One choice that's easy to miss: payments go to the burn address (0x000…dEaD). With 20 USDC in the agent's wallet at 0.001 USDC per call, that's enough for ~20,000 demo runs before re-funding, and it demonstrates a real on-chain settlement without needing a second wallet to act as a merchant. Every successful demo run is a verifiable transfer to a public address you can read on Basescan.

The other interesting detail is the retry-once-on-validAfter logic inside _verify_and_settle. EIP-3009 signatures carry a validAfter timestamp, and the CDP wallet sets that to the wall-clock time of signing. The facilitator, however, checks validAfter against the latest Base Sepolia block timestamp, and Base Sepolia has ~2 second blocks. So if you sign and submit fast enough, the latest block on-chain is still before your signature's validAfter and the facilitator rejects the settle. The fix is to wait one block and retry:

for attempt in range(2):
    status, settle = _call_facilitator("/settle", body)
    if status == 200 and settle.get("success", False):
        return True, None
    reason = settle.get("errorReason", "")
    if attempt == 0 and "valid_after" in reason:
        time.sleep(3)
        continue
    return False, f"settle failed: {reason}"

This is the kind of edge case you only learn the hard way. The first 50 invocations of the project failed with cryptic valid_after errors before I traced it to block-time skew.


The payments bootstrap

Before any of the above can run, AgentCore Payments needs four resources stood up in a specific order. The infra/payments/bootstrap.py script is idempotent (re-running it is a no-op) and creates them all in one shot:

graph LR
    IAM["IAM role<br/>(service trust)"] --> PM["PaymentManager"]
    PM --> Conn["Connector<br/>(CoinbaseCDP)"]
    Conn --> Inst["Instrument<br/>(wallet)"]
    Inst --> Sess["Session<br/>(spend limits)"]
          

The four resources, in order:

  1. IAM role: the role AgentCore Payments assumes when calling other AWS APIs on your behalf. Needs sts:SetContext, which the AWS-managed BedrockAgentCoreFullAccess policy is missing as of this writing.
  2. PaymentManager + Connector + CredentialProvider: the control plane. Created in one API call. The connector says "this manager talks to Coinbase CDP" and the credential provider stores your CDP API keys.
  3. PaymentInstrument: the data-plane resource that represents a specific wallet owned by a specific user. Creating one returns a redirectUrl for one-time delegated-signing consent. You open it in a browser and authorize AgentCore to sign transactions on behalf of that wallet.
  4. PaymentSession: a short-lived (60 min default) session with spend limits (e.g. max $1.00). The agent uses the session ID to authorize each ProcessPayment call. Sessions rotate; instruments are persistent.

The instrument-creation step is the one with a footgun: CDP treats each authentication method as a distinct end-user identity. If you sign in to the consent URL with Google OAuth, your wallet ends up owned by the Google identity. If you later open WalletHub and sign in with email OTP, WalletHub says "no accounts found," because your email-OTP identity has no wallets, even though the same email address is "linked" to the Google account. The README's inspect_cdp.py script exists specifically to debug that. Use email OTP from the start and you'll save yourself an evening.


How AgentCore Runtime deploys

Once the payment plumbing is up, deploying the agent itself is two commands. AgentCore has a CLI (agentcore) that wraps the build-and-deploy loop:

# Generate .bedrock_agentcore.yaml (entry point, region, runtime name).
uv run agentcore configure

# Build the container, push to ECR, register the runtime, swap in the new image.
uv run agentcore launch

Under the hood, agentcore launch does what you'd otherwise script yourself: docker buildx for arm64 (AgentCore Runtime is arm64-only), push to the ECR repo from the foundation Terraform, then call the AgentCore Runtime API to register or update the runtime with the new image digest. The Dockerfile is small:

FROM --platform=linux/arm64 public.ecr.aws/docker/library/python:3.11-slim

WORKDIR /app
RUN pip install --no-cache-dir uv==0.11.8

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev

COPY agent/ ./agent/

EXPOSE 8080
CMD ["uv", "run", "python", "-m", "agent.main"]

One non-obvious detail: I pull the Python base image from the public.ecr.aws mirror instead of Docker Hub. CodeBuild (which agentcore launch uses as the build backend) shares a single anonymous Docker Hub quota across all of AWS, and you hit 429s constantly. The ECR Public Gallery mirror has no such limit.


End-to-end

Once everything's deployed, invoking the agent is a normal boto3 call against the AgentCore Runtime data plane:

import boto3, json, uuid

dp = boto3.client("bedrock-agentcore", region_name="us-west-2")

payload = {
    "prompt": "Fetch the premium data from https://dqtf3wyiruv25.cloudfront.net/premium/data.json",
    "payment_session_id": "ps-...",
}

resp = dp.invoke_agent_runtime(
    agentRuntimeArn="arn:aws:bedrock-agentcore:us-west-2:...:runtime/agent_core-...",
    runtimeSessionId=uuid.uuid4().hex + uuid.uuid4().hex,  # must be >= 33 chars
    payload=json.dumps(payload).encode(),
)

print(resp["response"].read().decode())

What you get back is the premium JSON, pulled from a private S3 bucket the caller has no IAM access to, gated by an on-chain payment the model never saw. Running check_settlement.py right after confirms the transfer landed on Base Sepolia, with a basescan.org link to the transaction. The whole loop, prompt-to-tx, takes about 6 to 8 seconds, dominated by the facilitator's RPC round-trip and the validAfter block-wait.


Cost

One nice surprise of this stack: idle cost is roughly $0.10/month, almost entirely from ECR image storage (~7¢ for the lifecycle-managed 5-image limit). CloudFront, Lambda@Edge, AgentCore Runtime, and AgentCore Payments are all per-invocation. They charge $0 when nothing is hitting them. CloudFront's perpetual free tier (1 TB / 10M requests per month) absorbs all of my testing.

Per-invocation, you're paying: ~0.001 USDC for the on-chain settlement, a few cents of CodeBuild time per agentcore launch, and the standard Bedrock token cost for Nova Micro (which is rounding-error). For a project that touches five AWS services plus an external blockchain, that's hard to beat.

The big caveat: AgentCore Runtime and AgentCore Payments are still preview services. Preview→GA pricing can shift, and during this project's lifetime the bundled SDK and the actual API drifted on at least one field name (paymentConnectorId, which the SDK accepts in config but the API rejects in the request body). Treat preview as preview.


What I'd build next

A few directions this opens up:

  • Multiple paid tools. The plugin's after_tool_call hook is tool-agnostic. It just looks at the response. Add a second tool that hits a different paid API, and the same plugin pays for both. The agent suddenly becomes an autonomous consumer of any x402-enabled service it can reach.
  • Spend caps and circuit breakers. Payment sessions already carry a maxSpendAmount. Wire up SNS so the agent's owner gets paged when a session burns through its limit.
  • Mainnet. The only thing standing between this project and real-USDC mainnet is changing two constants in the merchant Lambda and the network ID in the payment instrument. Worth doing before relying on a public facilitator for anything important. Production deployments should self-host.

For now, this is the smallest end-to-end demo I could put together that proves AI agents can pay for their own work without a human's wallet. The full repo is at github.com/wgopar/bedrock-agentcore-x402.


Resources