Customer swaps

Let a customer convert one balance they hold into another.

A customer already holds a balance with you and wants a different asset instead — swap their USDC for ETH, or their SOL for USDC, without ever leaving Cheddar. You quote it, create it, and Cheddar handles the conversion and credits the result to the same customer.

Three swap-ish features exist — do not conflate them. Deposit swaps fund a customer balance from outside Cheddar. Swap converts an asset you hold and pays out to a whitelisted address. Customer swaps (this page) convert one customer balance into another — no outside token, and the result lands back with the same customer.

Customer swaps must be enabled for your organization. Until then every call returns 403 SWAPS_DISABLED. Contact the Cheddar team to switch it on.

How it works

StepCall
1Quote the conversionPOST /customer-swap/quote
2Create the swapPOST /customer-swap202 with the swap object
3Track it to completionyour CUSTOMER_DEPOSIT webhook (swap field), CUSTOMER_SWAP_FAILED, or GET /customer-swap/{swapId}

Amounts are atomic units

Every amount in this API — amountIn, estimatedAmountOut, minAmountOut, amountOut, feeAmount — is a whole-number string in the mint’s atomic units, never a decimal. USDC has 6 decimals, so 5 USDC is "5000000". ETH has 18 decimals, so 0.002 ETH is "2000000000000000". Look up a mint’s decimals from GET /mints before converting a human amount.

Only usdValue on the quote is a dollar decimal string, and it’s display only — never use it for accounting.

Quote a swap

curl -X POST https://api.cheddar.biz/v1/customer-swap/quote \
-H "x-api-key: YOUR_API_KEY" \
-H "content-type: application/json" \
-d '{
"customerId": "your-customer-id",
"fromMintId": 1,
"toMintId": 4,
"amountIn": "5000000"
}'
{
"fromMintId": 1,
"toMintId": 4,
"amountIn": "5000000",
"estimatedAmountOut": "1950000000000000",
"minAmountOut": "1930000000000000",
"feeBps": 50,
"feeAmount": "25000",
"usdValue": "5.00",
"estimatedTimeSeconds": 45
}

estimatedAmountOut and minAmountOut are atomic units of toMintId. estimatedTimeSeconds may be null when the provider doesn’t report one. Nothing is persisted by this call — quote as often as you like.

feeAmount (atomic units of fromMintId, at feeBps) is your platform fee, not a deduction from the swap. It is billed to your organization on your monthly invoice. Your customer receives estimatedAmountOut, which is the provider’s own output and already accounts for its delivery fee and slippage. Do not subtract feeAmount when you show the customer what they will get.

Create the swap

curl -X POST https://api.cheddar.biz/v1/customer-swap \
-H "x-api-key: YOUR_API_KEY" \
-H "content-type: application/json" \
-d '{
"customerId": "your-customer-id",
"fromMintId": 1,
"toMintId": 4,
"amountIn": "5000000",
"idempotencyKey": "unique-per-swap"
}'

Returns 202 Accepted with the swap object:

{
"swapId": "9f1e2b3a-2c1d-4e0a-9b0f-4c2e6a7d1f00",
"status": "PENDING",
"fromMintId": 1,
"toMintId": 4,
"amountIn": "5000000",
"estimatedAmountOut": "1950000000000000",
"minAmountOut": "1930000000000000",
"amountOut": null,
"originTxHash": null,
"destinationTxHash": null,
"failureReason": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"completedAt": null
}

customerEmail is optional and used only if the customer doesn’t already exist.

Idempotency

Use one idempotencyKey per swap you actually intend, not per HTTP attempt. Replaying the same key returns the existing swap rather than creating a second one — this is also how you safely retry after a 5xx or a timeout (see the retry rule under Errors, below).

Track the outcome

curl https://api.cheddar.biz/v1/customer-swap/9f1e2b3a-2c1d-4e0a-9b0f-4c2e6a7d1f00 \
-H "x-api-key: YOUR_API_KEY"

Returns the same swap object, scoped to your organization.

StatusTerminalMeaning
PENDINGNoCreated, waiting to be sent
PREFUNDINGNoSource balance was short; Cheddar is topping it up before sending
PROCESSINGNoSent to the swap provider
COMPLETEDYesDestination asset credited to the customer
FAILEDYesSee failureReason, and CUSTOMER_SWAP_FAILED below

amountOut, originTxHash, destinationTxHash, failureReason, and completedAt are null until they apply.

A completed swap’s destination credit arrives as your normal CUSTOMER_DEPOSIT webhook, with its swap field populated — see Webhook events for the payload. Poll GET /customer-swap/{swapId} if you’d rather not rely on webhooks alone.

Pair rules

A request that violates these returns INVALID_PAIR:

  • Source and destination must be different currencies — the same symbol on two chains (e.g. USDC on Ethereum → USDC on Solana) is not a swap.
  • USDC and USDT must use their Solana mint on either side of the swap.
  • Both mints must be swappable for your organization.

Use the swappable boolean on each entry from GET /mints to build your pair pickers, rather than hardcoding which mints can swap:

{
"id": 4,
"symbol": "ETH",
"chain": "ETHEREUM",
"usdPrice": "2600.00",
"decimals": 18,
"swappable": true
}

Errors

Every failure uses { error, code, providerMessage? }. providerMessage is present only on QUOTE_REJECTED.

codestatusmeaning
SWAPS_DISABLED403Not enabled for this organization
INVALID_PAIR400The two mints cannot be swapped — see pair rules above
BELOW_MINIMUM422Under Cheddar’s minimum swap size
QUOTE_REJECTED422The swap provider refused these inputs — permanent for this amount and pair. providerMessage carries its reason (for example a provider-side per-asset minimum). Change the amount or the pair; retrying unchanged fails again
INSUFFICIENT_LIQUIDITY409The source balance can’t cover it right now; nothing was created
QUOTE_UNAVAILABLE502Transient — retry
NOT_FOUND404Unknown swap id

The retry rule. A 4xx means definitively no swap was created — safe to treat as final. A 5xx or a timeout is ambiguous: the swap may exist even though you didn’t see the response. Don’t create a new one blind — replay POST /customer-swap with the same idempotencyKey and you’ll get the existing swap back instead of a duplicate.

Handling CUSTOMER_SWAP_FAILED

If a swap fails after being created, Cheddar sends CUSTOMER_SWAP_FAILED instead of a deposit webhook:

{
"eventType": "CUSTOMER_SWAP_FAILED",
"customerId": "your-customer-id",
"swap": { "customerSwapId": "9f1e2b3a-...", "amountIn": "5000000", "...": "..." },
"reason": "the swap provider could not route this pair",
"refundedAmount": "5000000",
"createdAt": "2026-01-01T00:05:00.000Z"
}

The swap object on both webhooks is a compact summary — customerSwapId, idempotencyKey, fromMint / toMint objects, amountIn, estimatedAmountOut, amountOut, the two tx hashes and createdAt. It is not the same shape the REST endpoints return, which uses swapId, fromMintId / toMintId and carries status and failureReason. Call GET /customer-swap/{swapId} when you need those.

refundedAmount is atomic source units, or null when nothing had come back yet at the time this event was sent. This is the signal to return the source amount to your customer’s usable balance — the source funds don’t move automatically back into what the customer can spend. See Webhook events for the full payload and the event catalog.