Going live
The five checks that separate a working sandbox from a store that can take real money.
A verified payout wallet on live, on every rail you accept
Live and sandbox wallets are separate rows. Proving one does not prove the other, and a rail without a proven address is simply not offered at checkout.
A live API key, server-side only
If a key has ever been in a browser bundle, a CI log, a screenshot or a chat message, revoke it. A replacement key is one click; a leaked live key is somebody else's payments API.
A live webhook endpoint over https
Subscribed to at least payment.confirmed and payment.overpaid — both mean paid.
Signature verification and idempotency, both tested
Not "implemented" — tested. Send a delivery with a mangled signature and confirm you refuse it. Send a valid one twice and confirm you fulfil once. See test mode.
One real payment, for a real small amount, on each rail
Test mode cannot tell you that your live wallet address is the one you think it is. Nothing else can either.
Four settings worth deciding before the first real payment
All four are in the dashboard under Settings → Store preferences. None is required, and the defaults are the safe ones — but each has a first payment where you would rather have chosen already.
Underpayment tolerance. An exchange withdrawal usually arrives a little short, because the exchange
takes its fee out of the amount sent rather than on top. At the default of 0 those payments end as
underpaid and wait for a person. Set a tolerance in basis points — 50 is 0.5%, enough for a typical
exchange fee — and a shortfall inside it confirms, firing payment.confirmed as normal. The maximum is
500. It is a property of the store, so it applies to live and to every sandbox alike, and it changes
what your webhook receives: with a tolerance set, amount_paid on a confirmed payment can be slightly
below amount. Fulfil on the status, not on your own comparison of the two figures.
Assets you accept. A switch per asset: CHR, USDC, USDT. Everything available is on by default, which includes assets added after you set this up. A payout wallet is per chain, so proving one BNB Smart Chain address otherwise means accepting all three there — this is how a store takes CHR and no stablecoins, or the other way round. Switching one off refuses new payments in it at creation, with a message naming the store rather than the rail. It never affects money already moving: a payment already open in that asset still completes, and a transfer already on its way is still detected, credited and paid out to your own wallet. Being able to receive an asset still needs a verified payout wallet on a chain that carries it, which is a separate page.
Branding. A logo URL (https, hosted by you) and a hex accent colour. The colour fills the pay button; the ink on it is chosen for contrast, so a pale brand does not produce unreadable white text. Chromia Pay's own line stays above your mark — a payer needs to recognise the store and see who is carrying the money.
Notifications. Email to whoever asks for it, for the two things a webhook cannot resolve on its
own: CHR that could not be matched to an order, and an order that expired holding a part payment.
Each person turns it on for themselves. Nothing is sent for payments that simply worked, and nothing is
sent for simulate_payment — an integrator driving the underpaid path in a loop is testing a handler,
not asking anyone to look at a payment.
Pausing, closing, and leaving
Under Settings → Pause or close, and worth knowing before you need it.
Pausing stops new payments being created and changes nothing else. POST /v1/payments answers
403, and a checkout link you sent five minutes ago still works, a transfer already on its way is
still watched and credited, and your webhooks still fire. That asymmetry is deliberate: stopping
selling must not strand a payer who is mid-checkout, and must never leave money arriving at an address
nobody is looking at. Resume from the same page.
Closing is pausing for good, and it cancels the payments that are still open — your integration
receives a payment.canceled for each one, so nothing is left waiting on an answer that will never
come. Payments that already confirmed are untouched and your history stays readable.
Deleting is offered only for a store that never took a payment. Once there are payments or deposits on record, those rows are the only account anywhere of money that moved between you and your customers — Chromia Pay holds no keys, so there is no statement to reconstruct them from — and they are never erased. Close the store instead: it stops selling and keeps the books.
Environment variables
CPAY_SECRET_KEY=cpay_sk_live_... # server-side only
CPAY_WEBHOOK_SECRET=whsec_... # per endpoint, shown once at creation
CPAY_API_URL=https://pay-api.chromia.comCheck that your .gitignore actually covers the env file before writing real values into it. A
tracked .env is the most common way a live key leaves a building.
Refunding
You can send money back; we cannot. The payout wallet is yours and so is the key, so the dashboard prepares the transfer and your own wallet signs it.
Open the payment, find the transfer under Transfers, and press Refund. The destination is prefilled with the address the money came from and the amount with whatever is left to return — both editable, and the amount can be partial, which is the usual case for an overpayment. You are then sent to a page that asks your wallet to sign. Nothing has moved until you approve it there.
Three things worth knowing before you need this:
- Check the destination. Prefilled is the address that paid you, which is right for a personal wallet and wrong for an exchange withdrawal: an exchange credits nobody for a transfer it was not expecting, and a second refund cannot bring it back.
- An asserted payout address cannot refund. Being paid needs a verified address; sending money back
needs a proven one, and those are not the same test. An address you asserted rather than proved with
a signature has no key to sign with, so Settings → Payout wallets marks it
receive onlyand the Refund button sayspayout wallet cannot signinstead of offering a form that would fail. For an exchange deposit address that is simply how it is — it receives fine and can never send. If it is your own wallet, prove it with Add or replace an address and refunds work from it immediately. - A transfer has to be final first. Refunding money that is only
seenwould send your own funds back for a payment that could still vanish in a reorg, so the option appears once it is confirmed.
A prepared refund holds the amount it is for until you sign it or give up on it, so nothing else can return the same money while you are deciding — that is what stops a transfer going back twice. Past half an hour the page stops offering to sign, but the hold stays: open the payment and press Give up on the row to release it. Do that only if you did not sign, because if you did, the money has already left and that row is the record of it.
Money in Reconcile — payments that arrived and match no order — offers the same button. Returning it is often the right answer when nobody can say what it was for.
When a payment does not confirm
Work down this list; it is ordered by how often each one is the answer.
- No verified payout wallet on that rail. Checkout could not offer it.
- The endpoint is not subscribed to that event.
payment.overpaidmissing is the classic. - Your consumer returned non-2xx, so the delivery is being retried and the endpoint is heading for disabled. Developers → Deliveries has every attempt, with its status code and duration — three 502s is a deploy, three ten-second timeouts is a handler doing work before it answers.
- Signature verification is failing because the body was parsed before it was verified. Symptom: every delivery rejected, and the secret is definitely correct.
- Wrong environment. A live payment against a sandbox endpoint, or the reverse. Check
livemodeon the event. - The customer underpaid. Status
underpaid, money in your wallet, payer identified. Reconcile page. - The link expired before they finished. Create a new payment; a checkout URL is not reusable.