> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pome.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Stripe twin in 5 minutes

> Start a Stripe account with an empty ledger, let your own coding agent take a payment and refund it, then read the twin's recorded tape and the two ledger entries it left behind. No Stripe account, no test keys, no real money, nothing graded.

<Info>
  <Icon icon="clock" /> **About 5 minutes.** You start a Stripe account whose
  ledger is completely empty, your own coding agent takes a \$42 payment and then
  refunds it, and you read the twin's own record of every call it made — plus the
  two balance entries that prove both legs happened.
</Info>

A **digital twin** is not a mock. It is a stateful service that answers the same
REST and MCP calls as `api.stripe.com`, boots from a declared starting state,
records every request, and never reaches Stripe. This is the twin that exists
for the operations you would never point at production: money moves, a ledger
records it, and none of it is real.

## Before you start

* **A Pome account.** `pome login` opens the browser sign-in and creates one if
  you do not have it. No credit card.
* **Your own coding agent** — Claude Code, Cursor, anything that can run a shell
  command and read JSON back.
* **Node 18+** for `npx`, plus `curl` and `jq` for the transcripts below. Your
  agent can read the raw JSON without `jq`; it is here to keep the blocks short.

Nothing else. No Stripe account, no test keys of your own, no
`ANTHROPIC_API_KEY`, no model inference paid for by Pome: **your agent is both
the operator and the actor.** It drives Pome, and it is the thing that acts on
the twin.

<Note>
  **Nothing on this page is graded.** No task file, no criteria, no score — and no
  agent eval is charged, because an eval is only ever burned when a run is graded.
  A sandbox you start, drive and stop costs you nothing. Grading appears exactly
  once in this curriculum, at the
  [support-triage capstone](/quickstart/claude-code), where the agent under test
  is sealed off from the criteria that judge it.
</Note>

## Paste this

Hand this to your coding agent as-is. It names the twin's own Stripe surface and
the boundary it must not cross.

```text theme={"dark"}
Set up a Pome Stripe sandbox, take a payment and refund it, then
show me the recorded tape.

Show me this plan first and wait for my go-ahead. Keep every
command plain text; never pipe a remote script into a shell.

1. Sign in and start the world:
     npx @pome-sh/cli@latest login
     npx @pome-sh/cli@latest sandbox create --twin stripe \
       --secrets-file .pome-sandbox.env --format json
   Then source .pome-sandbox.env. POME_AUTH_TOKEN is the bearer
   for EVERY call to the twin and the only one it accepts: any
   other token, or none, comes back as an opaque 404. Keep it in
   the shell, and add .pome-sandbox.env to .gitignore.

2. Read the world before you touch it. It is EMPTY -- no
   customers, no charges, no balance. Confirm that yourself:
     GET $POME_STRIPE_REST_URL/v1/balance
     GET .../v1/payment_intents   .../v1/charges
     .../v1/refunds   .../v1/customers   .../v1/events
   There is nothing seeded to refund, so you build the money.

3. Take the payment, then reverse it, through the twin's own
   Stripe surface -- the same routes and payloads api.stripe.com
   takes:
     POST /v1/payment_intents with amount 4200, currency usd,
       payment_method_types ["crypto"] and payment_method_options
       .crypto.mode "deposit" with deposit_options.networks
       ["base"]. It lands in requires_action with a real deposit
       address, and the twin tells you which fields it wants if
       you leave one out.
     POST /v1/test_helpers/payment_intents/<pi>/simulate_crypto_deposit
       settles it. That mints the charge.
     POST /v1/refunds with the charge id.
   Refund it ONCE. Do not cancel the PaymentIntent and do not
   retry the refund if a response looks slow.

4. Read the result back THROUGH THE TWIN, not from your memory
   of what you sent: the charge, /v1/balance, and
   /v1/balance_transactions. Tell me what the ledger holds.

5. Show me the tape, one line per call, in the order they
   happened: GET $POME_STRIPE_REST_URL/_pome/events

6. Stop the sandbox:
     npx @pome-sh/cli@latest sandbox stop $POME_SESSION_ID --discard

This is a sandbox, not an exam. Nothing here is scored and Pome is
not evaluating you. Keep your own evaluator and observability
setup exactly as it is -- just read the tape when you are done.
```

<Warning>
  **One token opens a sandbox: `POME_AUTH_TOKEN`.** It is the bearer on every call
  to the twin, REST and MCP alike. On most twins the secrets file also carries a
  provider-shaped token — `POME_GITHUB_TOKEN=github_pat_…`,
  `POME_STRIPE_API_KEY=sk_test_…` — and that one is *not* the bearer; it is what
  the twin serves inside the sandbox. The linear twin ships none at all.
  Send that provider-shaped token as the bearer, or send no bearer at all, and the
  proxy answers an opaque `404` reading

  `No twin pod for this session.` So a 404 on a sandbox you just created is almost
  always the wrong bearer rather than a dead sandbox.
</Warning>

## The world

`sandbox create` boots a Stripe twin from its declared starting state and hands
back the URLs that reach it. It also writes the connection secrets to
`.pome-sandbox.env` at mode `0600`, and says so on stderr.

```bash theme={"dark"}
npx @pome-sh/cli@latest sandbox create --twin stripe \
  --secrets-file .pome-sandbox.env --format json
```

```json theme={"dark"}
{
  "session_id": "ses_vNs4QWiaF8U9sB0z",
  "expires_at": "2026-08-25T10:26:52.744Z",
  "per_twin": {
    "stripe": {
      "api_url": "https://twins.pome.sh/stripe/s/ses_vNs4QWiaF8U9sB0z",
      "mcp_url": "https://twins.pome.sh/stripe/s/ses_vNs4QWiaF8U9sB0z/mcp"
    }
  },
  "agent_token": "***redacted***"
}
```

```bash theme={"dark"}
source .pome-sandbox.env

st() {
  curl -s -H "Authorization: Bearer $POME_AUTH_TOKEN" \
       "$POME_STRIPE_REST_URL$1" "${@:2}"
}
```

The world is an account that has never traded. Not "a test customer and a
sample charge" — nothing:

```bash theme={"dark"}
st /v1/balance | jq -c '{available, pending}'

for r in payment_intents charges refunds customers events; do
  st /v1/$r | jq -r --arg r "$r" '"\($r): \(.data|length)"'
done
```

```text theme={"dark"}
{"available":[],"pending":[]}
payment_intents: 0
charges: 0
refunds: 0
customers: 0
events: 0
```

`available` is not zero dollars — it is an empty list, because no currency has
ever been in this account. That is the starting state, and it is the same every
time you create this sandbox, which is what makes anything you observe next
reproducible. It also means **there is no seeded charge to refund: you build the
money first**, which is the honest version of this demo anyway.

```bash theme={"dark"}
PI=$(st /v1/payment_intents -X POST \
   -H 'content-type: application/json' \
   -d '{"amount":4200,"currency":"usd",
        "payment_method_types":["crypto"],
        "payment_method_options":{
          "crypto":{"mode":"deposit",
                    "deposit_options":{"networks":["base"]}}}}' \
   | tee /tmp/pi.json | jq -r .id)

jq -r '"\(.id)  \(.status)  \(.amount) \(.currency)"' /tmp/pi.json
jq -r '.next_action | "\(.type)",
  "  base: \(.crypto_display_details.deposit_addresses.base.address)"' \
  /tmp/pi.json
```

```text theme={"dark"}
pi_XzSSEggh3HihCUbI5PsiDf9N  requires_action  4200 usd
display_crypto_deposit_information
  base: 0xe2b9f701ad56d9cfc8ab62bcf07637158a1cd80e
```

Ids are minted per sandbox, so keep them in shell variables rather than pasting
the ones above. Settling the deposit is what mints the charge:

```bash theme={"dark"}
st /v1/test_helpers/payment_intents/$PI/simulate_crypto_deposit \
   -X POST -H 'content-type: application/json' -d '{}' \
  | jq -r '"\(.id)  \(.status)  received=\(.amount_received)",
           "charge: \(.latest_charge)"'

CH=$(st /v1/payment_intents/$PI | jq -r .latest_charge)
```

```text theme={"dark"}
pi_XzSSEggh3HihCUbI5PsiDf9N  succeeded  received=4200
charge: ch_GbfgeDCfDIDnMWnZ0Uy3Ir3a
```

Now the money is on the books — and this is the part you would never run twice
against production:

```bash theme={"dark"}
st /v1/balance | jq -c '.available'
st /v1/charges/$CH | jq -c '{amount, status, amount_refunded, refunded}'

st /v1/refunds -X POST -H 'content-type: application/json' \
   -d "{\"charge\":\"$CH\"}" \
  | jq -c '{object, amount, currency, status}'
```

```text theme={"dark"}
[{"currency":"usd","amount":4200,"source_types":{"card":0}}]
{"amount":4200,"status":"succeeded","amount_refunded":0,"refunded":false}
{"object":"refund","amount":4200,"currency":"usd","status":"succeeded"}
```

Read it back through the twin's own surface — not out of the response your agent
already holds:

```bash theme={"dark"}
st /v1/charges/$CH | jq -c '{amount, amount_refunded, refunded}'
st /v1/balance | jq -c '.available'
st /v1/balance_transactions | jq -c '.data[] | {type, amount, net, status}'
```

```text theme={"dark"}
{"amount":4200,"amount_refunded":4200,"refunded":true}
[{"currency":"usd","amount":0,"source_types":{"card":0}}]
{"type":"refund","amount":-4200,"net":-4200,"status":"available"}
{"type":"charge","amount":4200,"net":4200,"status":"available"}
```

**This is the thing worth noticing.** The balance is back to `0`, but it is not
back to `[]` — the account now holds a currency it has traded in, and the ledger
carries *both legs* rather than netting them away. `amount_refunded` moved
`0 → 4200` and `refunded` flipped to `true`, read back through a different route
from the one that changed them. The twin also kept its own event log:

```bash theme={"dark"}
st /v1/events | jq -r '.data[] | "\(.type)"'
```

```text theme={"dark"}
charge.refunded
refund.created
payment_intent.processing
payment_intent.succeeded
charge.succeeded
payment_intent.created
payment_intent.requires_action
```

Seven events for three writes, newest first, exactly as Stripe orders them —
because a settled payment and a reversed one each emit more than one. Stop the
sandbox and the whole account is gone.

## Read the tape

Every call above was recorded by the twin as it happened. This is the part
neither a mock nor a Stripe test account gives you: an account of the run
written by the service, not by the agent.

```bash theme={"dark"}
st /_pome/events | jq -r '.[]
  | "\(.method|(.+"     ")[:5])\(.path
      | sub("^/s/[^/]+";"")
      | sub("/(pi|ch)_[A-Za-z0-9]+";"/<id>")) -> \(.status)"'
```

```text theme={"dark"}
GET  /v1/balance -> 200
GET  /v1/payment_intents -> 200
GET  /v1/charges -> 200
GET  /v1/refunds -> 200
GET  /v1/customers -> 200
GET  /v1/events -> 200
POST /v1/payment_intents -> 200
POST /v1/test_helpers/payment_intents/<id>/simulate_crypto_deposit -> 200
GET  /v1/payment_intents/<id> -> 200
GET  /v1/balance -> 200
GET  /v1/charges/<id> -> 200
POST /v1/refunds -> 200
GET  /v1/charges/<id> -> 200
GET  /v1/balance -> 200
GET  /v1/balance_transactions -> 200
GET  /v1/events -> 200
```

Sixteen rows, and only three of them changed anything. Ask which:

```bash theme={"dark"}
st /_pome/events | jq -r '.[] | select(.state_mutation)
  | "\(.path | sub(".*/";""))  \(.status)"
  + "  mut=\(.state_mutation)  fid=\(.fidelity)"'
```

```text theme={"dark"}
payment_intents  200  mut=true  fid=semantic
simulate_crypto_deposit  200  mut=true  fid=semantic
refunds  200  mut=true  fid=semantic
```

**You should see:**

* **Sixteen rows, in the order they happened.** Read top to bottom and the money
  story is legible without asking the agent what it did: it checked the account
  was empty, it created an intent, it settled it, it refunded it, it reconciled.
* **`mut=true` on exactly three rows.** `state_mutation` means the call *landed*
  — a write the twin refuses reads `false`, not `true`. Thirteen of the sixteen
  calls here are reads, which is what a careful money agent's tape should look
  like.
* **`fid=semantic` on `/v1/refunds`.** That is the tier that matters most on this
  page: the refund route carries a full behavioural contract compared against a
  captured Stripe response, not a response shape with placeholder values. It
  refuses what Stripe refuses, with Stripe's own codes — send that same refund a
  second time and you get `400 charge_already_refunded`, not a duplicate row.
* **Three writes, seven events.** The tape counts what the agent did; the event
  log counts what the account did about it. They are different numbers on
  purpose.

When you are done, stop the sandbox to free the slot — it also expires on its own
30 minutes after it was created:

```bash theme={"dark"}
npx @pome-sh/cli@latest sandbox stop $POME_SESSION_ID --discard
```

## What you could assert here

Nothing on this page was graded, but a tape and a final state are exactly what a
graded check reads. The Stripe twin already declares these — no authoring
required, and `list_checks` on the Pome MCP prints the full set with what each
one actually compares:

| Declared check                                   | What it would catch here                                                                                                                                                                                                                                                                                                                                                                                 |
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `stripe.charge-exists-with-status` (`succeeded`) | An agent that creates the PaymentIntent, reports success and never settles it. A charge only exists once money moved, and the twin writes it with its ledger entry in one transaction, so this cannot pass on a half-finished payment.                                                                                                                                                                   |
| `stripe.refund-exists` (the charge)              | An agent that refunds *something* and calls it done. This names the charge, so refunding a different one does not satisfy it. It reads refund rows rather than the charge's `refunded` flag — that flag only turns true on a full refund, so a check reading it would miss every partial.                                                                                                                |
| `stripe.refund-count` (the charge, `1`)          | Over-refunding. Worth knowing what does and does not get here: retry the *full* refund above and the twin refuses it itself — `400 charge_already_refunded`, and the account still holds one refund row. It is a **partial** refund, retried, that lands a second row whose amount is right on its own and wrong in aggregate, and only a count sees that. `amount_refunded` looks plausible either way. |

Pointers, not a task. Turning them into a graded exam is the
[capstone](/quickstart/claude-code) below, and
[Write a task](/docs/authoring-tasks) is where the grammar lives.

## How real is this twin?

Every surface you called above is compared against a captured response from the
real Stripe API, re-run daily and published.
[The Stripe row on status.pome.sh](https://status.pome.sh) carries the current
count of twin responses that match, and it is the row worth reading carefully:
it does not read "all of them". A handful of Stripe surfaces have no captured
baseline we are willing to compare against, so instead of quietly counting them
as passes the row names each one as a **ruled exception with a published id and
reason**. A twin that drifts turns that row red; a twin we have not fully
verified says so out loud. Nobody has to take our word for the fidelity,
including us.

Which surfaces are covered, and which are shape-only, is on the
[Stripe twin reference](/docs/twins/stripe).

## Next: the one graded lesson

<CardGroup cols={2}>
  <Card title="The support-triage capstone" icon="graduation-cap" href="/quickstart/claude-code">
    The same twins, now an exam: a sealed agent under test, a deliberate failing
    score, and one line of prompt that turns it green.
  </Card>

  <Card title="Stripe twin reference" icon="credit-card" href="/docs/twins/stripe">
    Every route and MCP tool the twin serves, by use case, with its fidelity tier.
  </Card>

  <Card title="Write a task" icon="pen-line" href="/docs/authoring-tasks">
    Turn the checks above into a graded exam for your own agent.
  </Card>

  <Card title="pome sandbox" icon="terminal" href="/docs/cli/sandbox">
    Create, list and stop sandboxes — including multi-twin ones.
  </Card>
</CardGroup>
