---
title: "Sandbox to First Live Charge"
description: "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."
---

<MetaStrip items={[
  { label: "TIME", value: "~20 min" },
  { label: "STACK", value: (<><ServerChip name="@codespar/sdk" accent /><span style={{ color: "var(--color-fd-muted-foreground)", fontSize: 12 }}>or curl</span></>) },
  { label: "SERVERS", value: (<><ServerChip name="celcoin" /></>) },
]} />

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.

<EventPipeline
  title="SANDBOX CREDIT"
  subtitle="Confirm the environment, credit the wallet, read the balance"
  nodes={[
    { tag: "1 · CONFIRM", name: "Which environment is this key in", meta: "GET /v1/whoami · key.environment", tone: "validation" },
    { tag: "2 · CREDIT", name: "One call, or two legs", meta: "POST /v1/test/fund", tone: "event" },
    { tag: "3 · READ", name: "Balance and ledger", meta: "GET /v1/wallets/:id · balances[]", tone: "loop" },
  ]}
  loopSteps={[
    { tool: "POST /v1/test/pix-in", description: "Mints a real Celcoin cob. Credits nothing." },
    { tool: "POST /v1/test/settle-pix-in", description: "Credits the platform standin account, then mirrors into the wallet ledger." },
  ]}
  note="the two legs are the long path · the short path is POST /v1/test/fund alone · in a live project all three refuse with 403 before the body is parsed"
/>

## Prerequisites

```bash
npm install @codespar/sdk
```

The 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 `environment` is `test`. The key prefix does not decide this. See step 1.
- The key holds the `consumers:fund` scope. 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-celcoin` funding source, which is what [KYC onboarding](/docs/api/reference/kyc) provisions.

<Callout type="warn">
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](/docs/concepts/projects) before you assume a credit will show up where you are looking.
</Callout>

## 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.

```typescript title="check-environment.ts"
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");
}
```

```bash title="the same check, with curl"
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

```bash
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.

<Callout type="warn">
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.
</Callout>

## 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.

```typescript title="fund.ts"
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);
```

```bash title="the same call, with curl"
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`.

<Callout type="warn">
**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](/docs/concepts/meta-tools), the paywall and the payment-link gateways, and ignored under `/v1/test/`.
</Callout>

### 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`.

```bash
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.

<Callout type="warn">
**`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.
</Callout>

### 3c. Long path, leg 2: settle

```typescript title="settle.ts"
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.

<Callout type="error">
**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.
</Callout>

<Callout type="warn">
**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.
</Callout>

<Callout type="warn">
**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.
</Callout>

<Callout>
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.
</Callout>

## 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.

```typescript title="read-balance.ts"
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.

<Callout type="warn">
**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>`.
</Callout>

CLI equivalents for this whole sequence:

```bash
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_abc123
```

All 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](/docs/concepts/mandates) for the consent half, and the [consumer mandates reference](/docs/api/reference/consumer-mandates) 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 |

<Callout type="warn">
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.
</Callout>

## What this does not do

- **No meta-tool credits or settles.** `codespar_wallet` has exactly three actions: `balance`, `statement` and `receive`. There is no `fund` and no `settle`. `receive` mints a copy-and-paste code and stops there. Nothing in the meta-tool set closes the sandbox loop.
- **`codespar_get_started` does 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 `test` group,** even though the commands derive and run. `codespar test fund`, `codespar test pix-in` and `codespar test settle-pix-in` are not in the CLI docs, and neither are the three deprecated alias commands under `codespar consumers`.
- **There is no Python method.** The Python package ships no generated REST client and no `api` attribute. 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/fund` and `POST /v1/consumers/:consumerId/onramp-session` are 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/wallet` returns the mandate ceiling, and `GET /v1/wallets` filters on `status` and `agent_id` only. 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](/docs/errors).** `sandbox_funding_failed`, `pix_in_mint_failed` and `wallet_credit_failed` are missing from it. `no_celcoin_account` and `sandbox_funding_not_permitted` are there.
- **[Test mode](/docs/concepts/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

<NextStepsGrid items={[
  { label: "REFERENCE", title: "Test", description: "The three sandbox routes, field by field, with a runnable request on the page.", href: "/docs/api/reference/test" },
  { label: "CONCEPT", title: "Wallets", description: "What a wallet is, how balances post, and what the ledger records.", href: "/docs/concepts/wallets" },
  { label: "REFERENCE", title: "Consumer Mandates", description: "The spend body that turns a sandbox balance into a payment.", href: "/docs/api/reference/consumer-mandates" },
]} />
