Payments
Pay a third-party Bre-B key from USDC on Celo: quote, create, deposit, follow.
With the pay product, a party pays any Bre-B key in Colombian pesos from USDC on Celo. TuCOP
creates the payout with its payments provider and gives you the address and the exact amount the
payer sends.
Every request needs your consumer key (X-TuCOPRamp-Key or Authorization: Bearer) and a
party session in the X-TuCOPRamp-Party-Session header, for a party that
finished identity verification. Your consumer needs the pay product, or requests get
403 product_not_enabled. Session and party errors are the same as on
Destinations.
The order is fixed:
Register the destination
POST /destinationswithownership: "third_party"and the Bre-B key.owner_nameis optional: leave it out to look the key up and get the holder from the directory. Wait until it isverified. See Destinations.Show the holder and confirm
Show the payer the holder name, bank and document last 4, then
POST /destinations/{id}/confirm.Quote
POST /quoteswith the amount in COP. To show a price earlier, before there is a destination or a session, estimate it withGET /rates.Pay
Check that the payer's wallet holds enough USDC, then
POST /paymentswith the quote. Send exactlydeposit.amountfromreturn_address. See Send the deposit.Follow
operation.updatedwebhooks, orGET /payments/{id}. After the USDC arrive, the pesos usually reach the Bre-B key within 15 to 30 minutes. See How long it takes.
Limits
GET /limits returns the limits that apply to your integrator, and how much of the month is
used:
{
"pay": {
"minimum_per_operation": { "amount": "200000.00", "asset": "COP" },
"integrator_month": {
"limit": { "amount": "100000000.00", "asset": "COP" },
"used": { "amount": "1250000.00", "asset": "COP" },
"remaining": { "amount": "98750000.00", "asset": "COP" }
}
}
}
- The minimum is 200,000 COP per payment, unless TuCOP set another minimum for your integrator. It is never below the provider's floor of 4,000 COP.
- The monthly limit is 100,000,000 COP for your integrator, across all its parties, unless TuCOP set another one for you. It can be higher.
maximum_per_operationandintegrator_dayappear only when TuCOP set them for your integrator.- Months and days are calendar months and days in Colombia (
America/Bogota). Failed and canceled payments do not count.
The same checks run on every quote and every payment. An amount below the minimum answers
422 amount_below_minimum with the minimum that applies:
{
"type": "https://errors.tucopramp.xyz/amount-below-minimum",
"title": "Amount below minimum",
"status": 422,
"code": "amount_below_minimum",
"detail": "amount_cop is below the minimum.",
"minimum": { "amount": "200000.00", "asset": "COP" },
"request_id": "3b0f6c2e-1d4a-4f7e-9c8b-2a1e0d9f8c7b"
}
An amount over a limit answers 422 pay_limit_exceeded with check: per_operation,
integrator_day or integrator_month.
Estimate before you quote
When the payer types an amount in pesos, for example to pay a merchant's Bre-B key, show how many
USDC it takes before you register the destination or quote. GET /rates returns the provider's
current rate and, with amount_cop, an estimate priced with exactly the same math as a pay quote.
It needs only your consumer key, no party session, and writes nothing.
curl "https://sandbox.api.ramp.tucop.xyz/rates?amount_cop=150000" \
--header "X-TuCOPRamp-Key: $TUCOPRAMP_API_KEY"
amount_cop is the pesos that must reach the Bre-B key, in whole pesos (150000 or 150000.00).
The answer is 200. In this example TuCOP set a 100,000 COP minimum for the integrator:
{
"rate": {
"value": "4012.55",
"base": "USDC",
"quote": "COP",
"source": "provider",
"observed_at": "2026-10-13T15:03:00.000Z"
},
"pay": {
"fees": { "tucop_bps": 150, "fx_buffer_bps": 100, "consumer_bps": 50 },
"minimum_per_operation": { "amount": "100000.00", "asset": "COP" },
"rounding_mode": "ceil",
"quote_ttl_seconds": 120
},
"estimate": {
"product": "pay",
"binding": false,
"destination_amount": { "amount": "150000.00", "asset": "COP" },
"source_amount": { "amount": "38.34", "asset": "USDC" },
"fees": {
"tucop": { "amount": "0.580000", "asset": "USDC" },
"consumer": { "amount": "0.191700", "asset": "USDC" },
"fx_buffer": { "amount": "0.377288", "asset": "USDC" }
},
"total_amount": { "amount": "38.531700", "asset": "USDC" },
"rounding_mode": "ceil",
"within_limits": true,
"limit_error": null,
"limit_check": null,
"settlement_time": { "min_minutes": 15, "max_minutes": 30 },
"note": "Estimate only. Nothing is reserved and the rate moves. The amount to send is fixed by POST /quotes, which is valid for 2 minutes."
}
}
- Without
amount_cop,estimateisnull: you get the rate and your pay fees only. pay.feesare in basis points:tucop_bpsis TuCOP's fee for your integrator,consumer_bpsyour own fee.payisnullwhen your consumer does not havepay.within_limitssays whether a quote for this amount would pass your limits now. When it isfalse,limit_erroris the code the quote would answer with (amount_below_minimumorpay_limit_exceeded) and, forpay_limit_exceeded,limit_checknames the limit. The estimate is still returned.- An estimate needs the
payproduct. The rate alone needspay,withdrawordeposit. Otherwise the request gets403 product_not_enabled. - Each call reads the rate from the provider: at most 120 calls per minute per consumer
(
429 rate_limited_consumer). If the provider has no rate, the answer is502 rate_unavailable.
Quote
curl https://sandbox.api.ramp.tucop.xyz/quotes \
--request POST \
--header "X-TuCOPRamp-Key: $TUCOPRAMP_API_KEY" \
--header "X-TuCOPRamp-Party-Session: $PARTY_SESSION" \
--header "Idempotency-Key: 9a1d2c3b-4e5f-4a6b-8c7d-0e1f2a3b4c5d" \
--header "Content-Type: application/json" \
--data '{
"product": "pay",
"destination_id": "dst_01j9z3k8q2m4n6p8r0s2t4v6w8",
"amount_cop": { "amount": "200000.00", "asset": "COP" }
}'
amount_cop is what arrives at the destination: whole pesos, at least the minimum above. The
answer is 201:
{
"id": "qte_01j9z3k9a1b2c3d4e5f6g7h8j9",
"product": "pay",
"destination_id": "dst_01j9z3k8q2m4n6p8r0s2t4v6w8",
"destination_amount": { "amount": "200000.00", "asset": "COP" },
"source_amount": { "amount": "51.27", "asset": "USDC" },
"fees": {
"tucop": { "amount": "0.770000", "asset": "USDC" },
"consumer": { "amount": "0.256350", "asset": "USDC" },
"fx_buffer": { "amount": "0.500000", "asset": "USDC" }
},
"total_amount": { "amount": "51.526350", "asset": "USDC" },
"consumer_fee_wallet": "0x4444444444444444444444444444444444444444",
"rate": {
"value": "4000",
"base": "USDC",
"quote": "COP",
"source": "provider",
"observed_at": "2026-10-08T15:03:00.000Z"
},
"rounding_mode": "ceil",
"settlement_time": { "min_minutes": 15, "max_minutes": 30 },
"expires_at": "2026-10-08T15:05:00.000Z",
"created_at": "2026-10-08T15:03:00.000Z"
}
| Field | Meaning |
|---|---|
source_amount | USDC the payment costs, with the TuCOP fee and the buffer, in whole cents (at most 2 decimals). It is the amount the payment asks you to deposit: the payment's deposit.amount is the same number. |
fees.tucop | TuCOP's fee, a percent of source_amount that TuCOP sets for each integrator. The provider takes it from the deposit. |
fees.fx_buffer | A margin of 1 percent of the USDC converted, for rate movement between the quote and the deposit arriving. It is part of the price: when the rate does not move, it is not returned. |
fees.consumer | Your fee: the percent and wallet you set in the integrator console (Inicio for production, the Sandbox tab for sandbox), up to the maximum TuCOP allows (shown there), applied from the next quote. It is not part of the deposit: charge it on-chain yourself, to consumer_fee_wallet, in the same transaction the payer signs. |
total_amount | source_amount plus your fee: what the payer spends in total. |
rate | Colombian pesos per USDC as the provider gave it, and when. USDC is taken at one US dollar. |
rounding_mode | Every division is rounded up, so the payer never sends too little. |
settlement_time | How long the payout usually takes once the USDC arrive: the pesos reach the Bre-B key between min_minutes and max_minutes later. See How long it takes. |
A quote lasts 2 minutes and is used by one payment.
Pay
curl https://sandbox.api.ramp.tucop.xyz/payments \
--request POST \
--header "X-TuCOPRamp-Key: $TUCOPRAMP_API_KEY" \
--header "X-TuCOPRamp-Party-Session: $PARTY_SESSION" \
--header "Idempotency-Key: 3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f" \
--header "Content-Type: application/json" \
--data '{ "quote_id": "qte_01j9z3k9a1b2c3d4e5f6g7h8j9" }'
The destination must be verified and confirmed by the payer, or the request gets
409 destination_not_confirmed. The limits are checked again.
return_address is optional: the wallet the USDC comes from, where the provider returns it if the
payout fails. It defaults to the wallet the party session was opened with (a wallet session).
Without a wallet session, send it, or the request gets 422 return_address_required. Your app
answers for it; TuCOP checks only that it is a 0x address.
The answer is 201:
{
"id": "op_01j9z3kb5c6d7e8f9g0h1j2k3m",
"product": "pay",
"status": "awaiting_funds",
"quote_id": "qte_01j9z3k9a1b2c3d4e5f6g7h8j9",
"destination_id": "dst_01j9z3k8q2m4n6p8r0s2t4v6w8",
"destination_amount": { "amount": "200000.00", "asset": "COP" },
"source_amount": { "amount": "51.27", "asset": "USDC" },
"fees": {
"tucop": { "amount": "0.770000", "asset": "USDC" },
"consumer": { "amount": "0.256350", "asset": "USDC" }
},
"total_amount": { "amount": "51.526350", "asset": "USDC" },
"consumer_fee_wallet": "0x4444444444444444444444444444444444444444",
"deposit": {
"address": "0x5b1d7c0a4f2e8b9d6c3a1e0f7b2d4c6a8e9f0b1c",
"chain": "eip155:42220",
"asset": "USDC",
"token_contract": "0xcebA9300f2b948710d2653dD7B07f33A8B32118C",
"amount": { "amount": "51.27", "asset": "USDC" }
},
"return_address": "0x1111111111111111111111111111111111111111",
"error_code": null,
"settlement_time": { "min_minutes": 15, "max_minutes": 30 },
"created_at": "2026-10-08T15:03:40.218Z",
"updated_at": "2026-10-08T15:03:40.218Z"
}
Send exactly deposit.amount of USDC on Celo to deposit.address, from return_address.
deposit.amount has at most 2 decimals and is normally the quote's source_amount: TuCOP fixes
the amount with the provider when it creates the payment. Still send payment.deposit.amount,
never a number you kept from the quote: it is the amount the provider credits.
While the payment is awaiting_funds, the provider can change the amount it asks for. TuCOP then
updates deposit.amount and sends operation.updated again with the same status and the new
deposit_amount. So read deposit.amount from the latest GET /payments/{id} right before the
payer signs the transfer, not from the POST response or an older webhook.
If the rate moved enough since the quote that its source_amount no longer pays the destination,
POST /payments answers 409 quote_expired and nothing is created at the provider. Ask for a
new quote and pay with it.
If the request fails with a 5xx, retry it with the same Idempotency-Key: TuCOP carries on with
the same payment and never creates a second payout.
Send the deposit
The provider matches a deposit to a payment by who sent it and how much. A deposit is credited only when all of this holds:
- Exact amount. The USDC transfer is for exactly
deposit.amount(2 decimals). Not a cent more, not a cent less. Never send the quote'ssource_amountor a swap's output. - Same sender. The on-chain
fromof the USDC transfer isreturn_address. - One transfer. A single USDC
transferon Celo (token contract0xcebA9300f2b948710d2653dD7B07f33A8B32118C) todeposit.address. Do not split it.
These are the mistakes we see in practice:
| What happened | Result |
|---|---|
A swap (for example COPm to USDC through a DEX aggregator) sent its output straight to deposit.address. | The on-chain sender is the pool or router, not return_address, and the output never equals deposit.amount exactly. Not credited. |
The wallet sent an amount it computed itself, or kept from an older quote, instead of payment.deposit.amount. | It differs from the payment's amount. Not credited, returned by hand. |
The wallet did not hold deposit.amount and the transfer reverted (ERC20: transfer amount exceeds balance). | Nothing reached the provider. The payment stays awaiting_funds. |
| The app created a new payment on every retry. | Several open payments with the same amount, from the same wallet, to the same deposit address. A later deposit can be matched to an older payment. |
Check the balance first
Read the payer's USDC balance before POST /payments, and only create the payment when it covers
quote.source_amount. Before sending, check it again against payment.deposit.amount, the amount
you actually send.
With viem:
import { createPublicClient, http, erc20Abi, parseUnits } from 'viem';
import { celo } from 'viem/chains';
const USDC = '0xcebA9300f2b948710d2653dD7B07f33A8B32118C';
const client = createPublicClient({ chain: celo, transport: http() });
const balance = await client.readContract({
address: USDC,
abi: erc20Abi,
functionName: 'balanceOf',
args: [payerAddress]
});
// quote.source_amount.amount is a decimal string with at most 2 decimals, for example "51.27"
if (balance < parseUnits(quote.source_amount.amount, 6)) {
// Do not create the payment. Ask the payer to top up first.
}
// After POST /payments, before sending: the exact amount to send.
if (balance < parseUnits(payment.deposit.amount.amount, 6)) {
// Ask the payer to top up the difference, or cancel the payment.
}
Paying from another token
If the payer holds COPm (or any token other than USDC), do it in two steps, never in one:
- Swap to the payer's own wallet. Swap enough to cover
total_amountand leave the output in the payer's wallet. A swap always lands a little more or less than asked; keep the difference in the wallet. - Send the exact deposit. Check the balance, create the payment, and send exactly
deposit.amountfrom that same wallet todeposit.addresswith a plain ERC-20transfer.
import { createWalletClient, custom, erc20Abi, parseUnits } from 'viem';
import { celo } from 'viem/chains';
const wallet = createWalletClient({ chain: celo, transport: custom(window.ethereum) });
const [account] = await wallet.getAddresses();
// 1. The swap has already landed USDC in `account` (not in deposit.address).
// 2. Create the payment, then send exactly deposit.amount.
const payment = await createPayment(quote.id, { return_address: account });
const hash = await wallet.writeContract({
account,
address: payment.deposit.token_contract,
abi: erc20Abi,
functionName: 'transfer',
args: [payment.deposit.address, parseUnits(payment.deposit.amount.amount, 6)]
});
Use parseUnits on the decimal string. Do not go through a JavaScript number: floating point can
turn 51.27 into 51.269999.
If the payer would rather send whatever the swap lands, use a withdrawal to the party's own Bre-B key instead: a withdrawal address pays out any amount, with no exact figure to hit.
One open payment per payer
- Create the payment only when the payer is ready to send and the balance covers it.
- If the deposit transaction fails, send the deposit again for the same payment. Do not create a new payment for the same intent.
- If the payer backs out, cancel the payment before creating another one.
- Never keep two open payments for the same amount from the same wallet.
Cancel a payment
A payment can be canceled while it is creating or awaiting_funds and no deposit has been sent
for it. There are two ways it happens.
You cancel it
curl https://sandbox.api.ramp.tucop.xyz/payments/op_01j9z3kb5c6d7e8f9g0h1j2k3m/cancel \
--request POST \
--header "X-TuCOPRamp-Key: $TUCOPRAMP_API_KEY" \
--header "X-TuCOPRamp-Party-Session: $PARTY_SESSION" \
--header "Idempotency-Key: 7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a"
There is no body. The answer is 200 with the payment, now canceled:
{
"id": "op_01j9z3kb5c6d7e8f9g0h1j2k3m",
"product": "pay",
"status": "canceled",
"error_code": "canceled_by_integrator",
"updated_at": "2026-10-08T15:09:12.004Z"
}
(The response has every field of the payment; only the ones that changed are shown here.)
202with the payment stillawaiting_funds: the provider accepted the cancel and has not confirmed it yet. The payment endscanceledwitherror_codecanceled_by_integratorwithin a minute or two; theoperation.updatedwebhook tells you. Do not send the USDC.- Canceling a payment that is already
canceledanswers200with it again. 409 payment_not_cancelable: the payment is pastawaiting_funds(the deposit was detected, or it already ended). The error includes the currentpayment. Follow it instead.409 deposit_in_flight: a USDC transfer fromreturn_addresstodeposit.addressis already on-chain, but the provider has not detected it yet. Canceling now would strand those USDC, so TuCOP refuses. Wait for the payment to move on. If the deposit was wrong (amount or sender), it is handled through support, not by a cancel.404 payment_not_found: no such payment for this party and consumer.502 provider_unavailable: retry with the sameIdempotency-Key.
Only cancel when the payer will not send. Do not cancel a payment because it is still
awaiting_funds a few minutes after the deposit: that is the normal detection time (see
How long it takes).
TuCOP cancels it after 30 minutes
A payment that is still awaiting_funds 30 minutes after it was created, with no deposit
on-chain, is canceled automatically. It ends canceled with error_code expired_unfunded, and
you get the operation.updated webhook as usual.
Before canceling, TuCOP checks the chain for a USDC transfer from return_address to
deposit.address for exactly deposit.amount. When it finds one that the provider has not detected yet, the payment is never
canceled automatically, whatever its age: the provider usually picks the deposit up within minutes,
and TuCOP reviews it otherwise. So a deposit sent near the 30-minute mark is not lost.
The provider credits only a deposit sent from return_address for exactly deposit.amount.
USDC sent from any other wallet (a swap router, an exchange, another wallet of the payer), or for any
other amount (even a little more), is not credited to the payment and does not stop the cancel. The
provider sends it back to TuCOP, and TuCOP returns it to the payer through support@tucop.org.
To pay after a payment expired, ask for a new quote and create a new payment. Never send USDC to the
deposit.address of a canceled payment: it will not be credited.
Why a payment was canceled
error_code | Meaning |
|---|---|
canceled_by_integrator | You called POST /payments/{id}/cancel. |
expired_unfunded | No deposit arrived within 30 minutes of creating the payment. |
provider_canceled | The provider canceled it. Contact support@tucop.org with the id. |
Follow the payment
status | Meaning |
|---|---|
awaiting_funds | Waiting for the deposit. It stays here for about 15 minutes after the USDC land on-chain, while the provider detects them. |
processing | The provider has the USDC and is sending the pesos. Usually seconds to a few minutes. Not a failure. |
completed | The provider sent the pesos to the destination through Bre-B. Final. |
failed | The payout failed. error_code says why (provider_<reason>). Final. |
refunded | The USDC was returned to return_address. Final. |
canceled | Canceled before any deposit, by you or after 30 minutes without one. error_code says which. See Cancel a payment. Final. |
creating | Only after a request that failed with 5xx. Retry it with the same key. |
How you know the money was sent: the payment is completed. TuCOP cannot see the receiving
bank's own confirmation, but Bre-B is instant, so in practice the pesos are in the account once the
payment is completed. Withdrawal payouts use the same statuses; see
Withdrawals.
Each status change is sent once as an operation.updated webhook. While the
payment is awaiting_funds, a change of deposit.amount is also sent as operation.updated, with
the same status: do not read a repeated status as an error, and take the amount to send from
GET /payments/{id} right before sending. You can also poll GET /payments/{id}; when the
stored status is more than 30 seconds old, that call reads it from the provider again. A payment is visible only to the consumer and the party that made it.
There is no endpoint that lists payments.
How long it takes
After the USDC arrive at the deposit address, the pesos usually reach the Bre-B key within 15 to 30 minutes. Almost all of that time is the provider detecting the deposit:
| Phase | Typical time | status |
|---|---|---|
| USDC land on-chain, the provider has not detected them yet | about 15 minutes | awaiting_funds |
| The provider detected the deposit and sends the pesos through Bre-B | seconds to a few minutes | processing |
| The pesos reached the Bre-B key | - | completed |
So a payment that is still awaiting_funds a few minutes after the deposit transaction confirmed
is normal: do not retry, cancel or send the USDC again. TuCOP does not auto-cancel a payment whose
exact deposit from return_address is already on-chain. If it is still awaiting_funds 30 minutes after the deposit, check
the deposit against Send the deposit (amount, sender, token) before anything
else. The operation.updated webhook tells you when it is completed (or
failed or refunded).
Every payment, quote and GET /rates estimate carries the expected window as
settlement_time, in minutes after the USDC arrive:
"settlement_time": { "min_minutes": 15, "max_minutes": 30 }
Show it to the payer, for example "Los pesos llegan a la llave Bre-B entre 15 y 30 minutos después de que recibimos los USDC." Read it from the response instead of hard-coding it: TuCOP may update it. It is an expectation, not a guarantee; the status is what says the pesos arrived.
Errors
| Code | Status | When |
|---|---|---|
invalid_amount | 400 | amount_cop is not { "amount": "<decimal>", "asset": "COP" } (a decimal string on GET /rates), is not positive, or has more than 2 decimals. |
amount_not_settleable | 400 | amount_cop is not whole pesos. |
invalid_return_address | 400 | return_address is not a 0x address. |
product_not_enabled | 403 | Your consumer does not have pay. |
party_not_linked | 403 | The party withdrew its consent to your app. |
party_restricted | 403 | TuCOP restricted the party. |
integrator_customer_mismatch | 403 | Your integrator pays from its own payments provider account, and the session is for another party. |
destination_not_found | 404 | No such destination for this party and consumer. |
quote_not_found | 404 | No such quote for this party and consumer. |
payment_not_found | 404 | No such payment for this party and consumer. |
party_not_verified | 409 | The party has not finished identity verification. |
destination_not_verified | 409 | The destination is still pending or was rejected. |
destination_not_confirmed | 409 | The payer has not confirmed the holder. |
quote_expired | 409 | The quote expired, or the rate moved and it no longer pays the destination. Ask for a new quote. |
quote_already_used | 409 | Each quote pays once. Ask for a new one. |
payment_not_cancelable | 409 | Cancel: the payment is no longer awaiting_funds. The error includes payment. |
deposit_in_flight | 409 | Cancel: a deposit for the payment is on-chain and about to be detected. Wait for it. |
integrator_not_linked | 409 | No session was sent, and your integrator's payments provider account is not linked yet. |
amount_below_minimum | 422 | Below the minimum. The error includes minimum. |
pay_limit_exceeded | 422 | Over a limit. The error includes check: per_operation, integrator_day or integrator_month. |
destination_not_third_party | 422 | pay pays third parties. Use withdraw for the party's own account. |
return_address_required | 422 | The session has no wallet and no return_address was sent. |
payment_rejected | 422 | The provider refused the payout. The quote is used; ask for a new one. |
rate_unavailable | 502 | The provider has no rate right now. Retry. |
provider_error | 502 | The provider did not return deposit instructions. |