Testing payments
You can take a checkout all the way to completed without touching a chain. The sandbox
simulates the deposit, and every listener, webhook, invoice and state transition after it
is the same code that runs in live.
Two keys, and you need both
The environment is decided by a key's prefix, not by a flag, so there is no way to send a sandbox request with a live key by accident.
| Call | Key |
|---|---|
/v1/payments/** | pmk_test_… — your payment secret key |
/v1/sandbox/** | flk_… — your ordinary ChainOS API key, in sandbox |
A payment key is refused everywhere except /v1/payments/**, with a message saying
so. That is deliberate: a payment key is issued to run a checkout, and a gateway
integration that was also a full account credential would be a much larger thing to leak.
Simulating a deposit is a ledger operation, so it takes the ordinary key.
The economic minimum is not enforced in sandbox. A one-cent test payment is how an integration gets tried, and refusing it would teach you nothing about your own code.
Drive a payment to confirmed
# 1. Create a session. Native asset, for the reason in the note below.
SESSION=$(curl -s -X POST $CHAINOS/v1/payments/sessions \
-H "X-API-Key: $CHAINOS_PAYMENT_TEST_KEY" \
-H 'Content-Type: application/json' \
-d '{"chain":"base","asset":"ETH","amount":"0.01","reference":"order_test_1"}')
ADDRESS=$(echo "$SESSION" | jq -r .data.address)
# 2. Simulate the deposit, and let it confirm on its own.
# The ordinary API key here, not the payment key — see the table above.
curl -s -X POST $CHAINOS/v1/sandbox/simulate-deposit \
-H "X-API-Key: $CHAINOS_SANDBOX_KEY" \
-H 'Content-Type: application/json' \
-d "{\"address\":\"$ADDRESS\",\"chain\":\"base\",\"amount\":\"0.01\",\"autoConfirm\":true}"
Your payment_detected webhook fires immediately and payment_confirmed follows. The
invoice is issued and emailed — in the local stack, read it at
localhost:8025.
simulate-deposit denominates amount in the chain's own currency and refuses a
tokenContract rather than ignoring it, because a caller who asked for a USDT deposit
must not be handed a native one and told it worked.
So test the state machine, the webhooks and your fulfilment code against a native-asset session. Test the token path on a testnet, where the token is real.
confirmAfterSeconds instead of autoConfirm gives you a window to look at the
detected state, and simulate-confirmation steps the confirmation count by hand when
you want to watch the meter fill.
The four cases nobody tests
Every one of these is a real day in production and none of them is the happy path.
1. Underpayment
# A session for 1.00, paid 0.90.
curl -X POST $CHAINOS/v1/sandbox/simulate-deposit \
-H "X-API-Key: $CHAINOS_SANDBOX_KEY" … -d '{"amount":"0.90", …}'
Expect payment_underpaid, status underpaid, and amountRemaining of 0.10. The
address stays open, so a second deposit of 0.10 completes it — test that too, because a
merchant who treats underpaid as failed will refund a customer who was about to top up.
Set underpaymentToleranceBps on creation if you want small shortfalls to count. A few
basis points covers the common case: a sending exchange that deducts its withdrawal fee
from the amount rather than adding it.
2. Overpayment
Pay more than was asked. Expect payment_overpaid with a surplus, and a session that
still completes. Decide now what you do with the surplus; deciding it while a customer
waits is worse.
3. Late payment
The one that costs money to get wrong.
# A session with a short window.
… -d '{"chain":"base","asset":"ETH","amount":"0.01","expiresInSeconds":60}'
# Wait for payment_expired, THEN deposit.
sleep 70
curl -X POST $CHAINOS/v1/sandbox/simulate-deposit …
Expect payment_expired first, then payment_late, and a session at expired_paid — not
expired. The address was not archived, and the funds are credited.
Assert in your own test suite that your fulfilment code recovers this order. If your
system releases stock on payment_expired, this is where you find out.
4. Wrong asset
Send the chain's native asset to a session expecting a token. Expect
payment_wrong_asset, a receipt recorded with counted: false, and a session total that
did not move. The portal shows it struck through on the payment's detail screen.
Testing the embedded component
The overlay needs an origin it is allowed to be framed from. Register your development origin on the publishable key — including the port:
{ "origins": ["http://localhost:5173", "https://shop.example.com"] }
Origins are compared as whole strings. http://localhost:5173 and
http://127.0.0.1:5173 are different origins and both need registering if you use both.
If the overlay loads blank, the browser console will carry a frame-ancestors violation —
that is the origin list, not a bug in the component.
Resetting
DELETE /v1/sandbox/reset clears simulated state. It does not un-derive addresses:
derivation indices are never reused, in sandbox or anywhere else, because an index that
came back would eventually produce two customers with the same address.
A test worth writing once
The single highest-value test in a payments integration is not a happy path:
it('does not ship when the amount is short', async () => {
const webhook = signedPaymentConfirmed({
merchantReference: 'order_44182',
amountPaid: '0.01', // the order costs 100.00
amountWasClientAsserted: true,
});
await post('/webhooks/chainos', webhook);
expect(await orders.get('order_44182')).toMatchObject({ status: 'review' });
expect(shipments.create).not.toHaveBeenCalled();
});
If that test passes, the expensive mistake in Verifying payments cannot reach your customers.