Sandbox to First Live Charge
Put money in a test wallet, run the whole flow against it, and see what changes when you swap the key. The order the writes happen in, what has already happened when each refusal arrives, and which refusals a retry can fix.
Credit a consumer wallet in a test project, read the balance where it actually lives, spend against it, then swap to a live key and watch the same three calls refuse.
Prerequisites
npm install @codespar/sdkThe generic REST client used below (cs.api) landed in @codespar/sdk 0.12.0. Three things have to be true before any of this works:
- The key resolves to a project whose
environmentistest. The key prefix does not decide this. See step 1. - The key holds the
consumers:fundscope. All six funding routes require it. - The organization has a Celcoin connection. The short path also needs the consumer to already have an active
pix-celcoinfunding source, which is what KYC onboarding provisions.
Wallets are scoped per project. Celcoin funding sources are scoped per organization. Two keys for two projects in the same org see the same Celcoin account and two different wallets. Read Projects before you assume a credit will show up where you are looking.
1. Confirm the environment first
The environment comes from the projects row that the key resolves to, not from the key string. csk_live_ and csk_test_ both pass the format check, and the prefix is never read to pick an environment. Ask the server.
import { CodeSpar } from "@codespar/sdk";
const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY! });
const who = await cs.api.response("get", "/v1/whoami");
if (!who.ok) {
throw new Error(`whoami refused with ${who.status}`);
}
const environment = who.data.key.environment;
const scopes = who.data.key.scopes;
if (environment !== "test") {
throw new Error(`project environment is "${environment}"; sandbox funding will 403 here`);
}
if (!scopes.includes("consumers:fund")) {
throw new Error("this key does not hold consumers:fund");
}curl -s https://api.codespar.dev/v1/whoami \
-H "authorization: Bearer $CODESPAR_API_KEY" \
| jq '{ environment: .key.environment, scopes: .key.scopes }'cs.api.response(...) returns ok, status and data and never throws on a refusal. cs.api.request(...) is the same call that throws on any non-2xx. Use response here, because the interesting part of this recipe is the refusals.
2. Create the consumer
curl -X POST https://api.codespar.dev/v1/consumers \
-H "authorization: Bearer $CODESPAR_API_KEY" \
-H "content-type: application/json" \
-d '{ "display_name": "Ana Souza" }'The id is minted server side and comes back with a cons_ prefix. The create body accepts only display_name and metadata. Sending document returns 400.
Everywhere else in this recipe, consumer_id is a free string of 1 to 256 characters, not a validated id. A wallet is created for whatever string you send. A typo does not raise an error: it mints a second wallet and credits that one. The generated example on the reference page uses csm_0000000000000000, which is a placeholder shape and not the real prefix.
3. Choose one of the two credit paths
They are not the same flow and they do not credit the same provider account. The wallet ledger gets the same mirror either way.
3a. Short path: one call
POST /v1/test/fund resolves the consumer's active Celcoin account, credits that account in the Celcoin sandbox, and only then mirrors the credit into the wallet ledger. amount_minor is the only required field. It is in centavos, and it must be a positive integer.
const funded = await cs.api.response("post", "/v1/test/fund", {
body: { consumer_id: consumerId, amount_minor: 10_000 },
});
if (funded.status === 422) {
// no_celcoin_account: the consumer has no active pix-celcoin funding source.
// Nothing was credited. Run onboarding, or pass an explicit `account`.
throw new Error("consumer has no Celcoin account");
}
if (funded.status === 500) {
// wallet_credit_failed: the sandbox account WAS credited and the ledger
// mirror failed. Reconcile by deposit_id. Do not retry.
throw new Error(`reconcile deposit ${funded.data.details.deposit_id}`);
}
if (!funded.ok) {
throw new Error(`fund refused with ${funded.status}`);
}
console.log(funded.data.deposit_id, funded.data.amount_minor, funded.data.money_credited);curl -X POST https://api.codespar.dev/v1/test/fund \
-H "authorization: Bearer $CODESPAR_API_KEY" \
-H "content-type: application/json" \
-d '{ "consumer_id": "cons_abc123", "amount_minor": 10000 }'A 201 carries attempt_id, deposit_id, status, account, amount_minor, currency of "BRL", and money_credited.
This route is not idempotent, and nothing in the schema says so. The external reference it writes to the ledger is generated per call, so two identical requests credit twice. The Idempotency-Key header does not help: it is read by the agent transport, the paywall and the payment-link gateways, and ignored under /v1/test/.
3b. Long path, leg 1: mint
POST /v1/test/pix-in mints a real dynamic Celcoin cob against the platform standin key. Nothing is credited. Keep the transaction_id.
curl -X POST https://api.codespar.dev/v1/test/pix-in \
-H "authorization: Bearer $CODESPAR_API_KEY" \
-H "content-type: application/json" \
-d '{ "consumer_id": "cons_abc123", "amount_minor": 10000, "description": "test top-up" }'A 201 carries pix_copia_e_cola, transaction_id, pix_key, amount_minor, currency of "BRL", and rail of "pix-celcoin". description is capped at 140 characters. This leg does not need a funding source on the consumer, because the standin key override skips the lookup entirely.
transaction_id can come back null. The handler looks for transactionId, txid or id in the Celcoin response and returns null when it finds none. The OpenAPI document declares it a required string. A null flows straight into the settle call and lands you in the trap below. Check it before you use it.
3c. Long path, leg 2: settle
const minted = await cs.api.response("post", "/v1/test/pix-in", {
body: { consumer_id: consumerId, amount_minor: 10_000, description: "test top-up" },
});
if (!minted.ok) throw new Error(`mint refused with ${minted.status}`);
const transactionId = minted.data.transaction_id;
if (!transactionId) {
// Without this, settle generates its own reference and idempotency is gone.
throw new Error("mint returned no transaction_id");
}
const settled = await cs.api.response("post", "/v1/test/settle-pix-in", {
body: { consumer_id: consumerId, amount_minor: 10_000, transaction_id: transactionId },
});
if (settled.status === 500) {
// wallet_credit_failed: the standin account was credited, the ledger was not.
throw new Error(`reconcile deposit ${settled.data.details.deposit_id}`);
}
if (!settled.ok) throw new Error(`settle refused with ${settled.status}`);Settle does two writes, in this order, and they are not in one transaction. First it credits the platform standin account at Celcoin, using transaction_id as the client code. Only then does it get or create the consumer wallet and post the credit to the ledger, using transaction_id as the external reference. If the first write lands and the second fails, you get 500 wallet_credit_failed with details.deposit_id, and the sandbox money has already moved at the provider.
Settle verifies nothing about the mint. There is no lookup of the transaction_id, no comparison against the minted amount, no check that the cob exists. You can settle R$ 500 against a cob of R$ 10, or settle an id you invented, and the 201 looks the same. The transaction_id is used only as the client code at Celcoin and as the external reference in the ledger.
A repeated settle returns 201 with settled: true and money_credited: true even when nothing was credited. The ledger deduplicates on the external reference and reports that it did, but the route discards that result and builds the response out of the request body. First credit and no-op are indistinguishable in the response. And transaction_id is optional in the handler's schema while the document marks it required: omit it and the route mints its own reference instead of refusing, so idempotency disappears without an error.
The two paths credit different provider accounts. POST /v1/test/fund credits the consumer's own Celcoin account. POST /v1/test/settle-pix-in credits the platform standin account, a fixed account number shared by every settle in the environment. The consumer wallet ledger receives the same mirror in both cases, so anyone who checks the consumer's balance at Celcoin after a pix-in settle finds nothing there.
There are three older aliases under /v1/consumers/:consumerId/fund/, one per route, marked deprecated in the document and kept for two releases. Write the canonical /v1/test/ form. The one real behavioral difference: on an alias the path segment wins and the body's consumer_id is ignored.
4. Read the balance in the right place
None of the three sandbox routes returns the wallet id they just credited. The bridge from a consumer to a wallet id is a list plus a metadata match: metadata.consumer_id equal to your consumer, and metadata.kind equal to "directed-pay". That is the same pair the internal lookup uses. The display name is the word Consumer followed by the consumer id.
const list = await cs.api.response("get", "/v1/wallets");
if (!list.ok) throw new Error(`wallets list refused with ${list.status}`);
const wallet = list.data.wallets.find(
(w) => w.metadata?.consumer_id === consumerId && w.metadata?.kind === "directed-pay",
);
if (!wallet) throw new Error(`no directed-pay wallet for ${consumerId}`);
const balance = await cs.api.response("get", "/v1/wallets/{id}", { path: { id: wallet.id } });
console.log(balance.data.balances);
const ledger = await cs.api.response("get", "/v1/wallets/{id}/ledger", { path: { id: wallet.id } });
const credits = ledger.data.entries?.filter((e) => e.kind === "fund");
console.log(credits);balances[] reports balance_minor and available_minor as bigint strings, not numbers. The ledger comes back newest first, pages with a before_id cursor, and takes an optional kind filter. The credit you just posted is the entry with kind of "fund", carrying the external reference from step 3.
The route that looks like the balance is not the balance. GET /v1/consumers/:id/wallet returns a mandate rollup: authorized_minor is the sum of the slot caps on active mandates, which is a ceiling and not money. It reads exactly the same before and after you credit R$ 100. A consumer with no mandates gets an empty currencies array rather than a 404. The same trap has a worse shape in the CLI: codespar wallet <consumer> calls the mandate rollup, and the balance is codespar wallets get <walletId>.
CLI equivalents for this whole sequence:
codespar test fund -i '{"consumer_id":"cons_abc123","amount_minor":10000}'
codespar test pix-in -i '{"consumer_id":"cons_abc123","amount_minor":10000}'
codespar test settle-pix-in -i '{"consumer_id":"cons_abc123","amount_minor":10000,"transaction_id":"..."}'
codespar wallets list
codespar wallets get wlt_abc123
codespar wallets list-ledger wlt_abc123All three test commands take a JSON body through -i or a file through -f, and have no positional arguments. codespar ledger is something else entirely: it is the codespar_ledger meta-tool.
5. Spend against the balance
Money leaves under a signed mandate, and a mandate is born in the consent flow: POST /v1/consents/init, then POST /v1/consents. The spend itself is POST /v1/consumers/mandates/{id}/spend, with a body of amount_minor and payee plus the optional agent_id, attempt_id, ted and quote. The balance you credited in step 3 is what the hold validates against. See Mandates for the consent half, and the consumer mandates reference for the spend body.
6. What changes when you swap the key
Point the same script at a key whose project is live and the three funding routes stop existing in practice. Each one returns 403 sandbox_funding_not_permitted with details.environment, and it fires as the first statement of the handler: before the body is parsed, before the database is touched, before any provider call. Nothing else in the request matters. A retry never fixes it. Only a key whose project is test does.
One more thing moves with the environment, and it is not the CodeSpar base URL. api.codespar.dev is the same in both. What changes is the upstream host: a test project only talks to the host named by the provider's test venue classification, and is refused outright when the provider has no test venue at all.
Refusals, and which ones a retry fixes
| Status and code | What already happened | Retry |
|---|---|---|
400 invalid_body | Nothing. The body did not match the schema: a missing, zero, negative or non-integer amount_minor, or a description over 140. details.issues carries the validation issues. | Only with a corrected body |
400 missing_required_field | Nothing. Neither the path segment nor the body carried a consumer. Only on the /v1/test/ form. | Only with the field |
403 sandbox_funding_not_permitted | Nothing. The project is live. First statement of the handler, before body parsing. details.environment names the environment. | Never. Use a test project key |
403 forbidden | Nothing. The key lacks consumers:fund. This one has a flat envelope of error, message and status, with no request_id, and it is not in the OpenAPI for any of the six routes. | Never. Use a key with the scope |
422 no_celcoin_account | Nothing. Only on POST /v1/test/fund: the consumer has no active pix-celcoin funding source and no explicit account was passed. | Never. Onboard, or pass account |
502 sandbox_funding_failed | Nothing was mirrored into the ledger. Celcoin refused the credit. The served document declares 422 for this code and the service returns 502. | Yes, if the provider was transient |
502 pix_in_mint_failed | Nothing. Celcoin refused the mint. Same document mismatch: the document says 422, the service returns 502. This code also swallows missing credentials and proxy errors. | Only for provider downtime, not for a missing connection |
500 wallet_credit_failed | Money moved. The sandbox account was credited and the ledger mirror failed. details.deposit_id is the reconciliation handle. Documented on the settle routes only, and returned by POST /v1/test/fund as well. | No. A retry credits again. Reconcile by deposit_id |
404 transaction_not_found | Unknown. On the onramp poll, three distinct failures collapse into this code: no connected provider account, a credential that no longer dereferences, or a non-2xx from the provider, which covers both an unknown id and an outage. | Only the third case, and the response does not say which it was |
Never read that 404 as "the transaction does not exist", and never treat it as permission to open a second onramp. Two of its three causes are on your side of the connection.
What this does not do
- No meta-tool credits or settles.
codespar_wallethas exactly three actions:balance,statementandreceive. There is nofundand nosettle.receivemints a copy-and-paste code and stops there. Nothing in the meta-tool set closes the sandbox loop. codespar_get_starteddoes not mention these routes. Its guidance walks through shop, wallet, pay and charge, and never says how a minted cob is settled in test. It also executes nothing: it returns text.- There is no CLI reference page for the
testgroup, even though the commands derive and run.codespar test fund,codespar test pix-inandcodespar test settle-pix-inare not in the CLI docs, and neither are the three deprecated alias commands undercodespar consumers. - There is no Python method. The Python package ships no generated REST client and no
apiattribute. None of these six routes is callable from it except over raw HTTP. - The route that starts live funding is not in the served document.
POST /v1/consumers/:consumerId/fundandPOST /v1/consumers/:consumerId/onramp-sessionare registered in the service and absent from the OpenAPI snapshot. Only the poll is documented, which means the document teaches you to follow a transaction id it does not teach you to create. - There is no read route for a balance by
consumer_id.GET /v1/consumers/:id/walletreturns the mandate ceiling, andGET /v1/walletsfilters onstatusandagent_idonly. The consumer to wallet id bridge is the metadata match in step 4. - Three of the refusals above have no entry in the error reference.
sandbox_funding_failed,pix_in_mint_failedandwallet_credit_failedare missing from it.no_celcoin_accountandsandbox_funding_not_permittedare there. - Test mode is a different subject. That page is about declared mocks in a session, not about these routes, and its claim that the key prefix is a visual convention rather than an authorization gate is correct about the prefix and silent about the gate. The project's environment is the gate.
Next steps
Webhook Providers Reference
Per-provider inbound webhook signature schemes — HMAC-SHA256, ECDSA P-256, HTTP Basic, shared-secret. For self-hosters and operators verifying provider events.
KYC Onboarding
From zero to a provisioned consumer account: open the application, poll the status, and read what exists after approval. The order each handler runs in, the two shapes the POST answers in, and which refusal a retry actually fixes.