TuCOPRamp

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:

  1. Register the destination

    POST /destinations with ownership: "third_party" and the Bre-B key. owner_name is optional: leave it out to look the key up and get the holder from the directory. Wait until it is verified. See Destinations.

  2. Show the holder and confirm

    Show the payer the holder name, bank and document last 4, then POST /destinations/{id}/confirm.

  3. Quote

    POST /quotes with the amount in COP. To show a price earlier, before there is a destination or a session, estimate it with GET /rates.

  4. Pay

    Check that the payer's wallet holds enough USDC, then POST /payments with the quote. Send exactly deposit.amount from return_address. See Send the deposit.

  5. Follow

    operation.updated webhooks, or GET /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:

json
{
  "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_operation and integrator_day appear 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:

json
{
  "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.

bash
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:

json
{
  "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, estimate is null: you get the rate and your pay fees only.
  • pay.fees are in basis points: tucop_bps is TuCOP's fee for your integrator, consumer_bps your own fee. pay is null when your consumer does not have pay.
  • within_limits says whether a quote for this amount would pass your limits now. When it is false, limit_error is the code the quote would answer with (amount_below_minimum or pay_limit_exceeded) and, for pay_limit_exceeded, limit_check names the limit. The estimate is still returned.
  • An estimate needs the pay product. The rate alone needs pay, withdraw or deposit. Otherwise the request gets 403 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 is 502 rate_unavailable.

Quote

bash
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:

json
{
  "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"
}
FieldMeaning
source_amountUSDC 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.tucopTuCOP's fee, a percent of source_amount that TuCOP sets for each integrator. The provider takes it from the deposit.
fees.fx_bufferA 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.consumerYour 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_amountsource_amount plus your fee: what the payer spends in total.
rateColombian pesos per USDC as the provider gave it, and when. USDC is taken at one US dollar.
rounding_modeEvery division is rounded up, so the payer never sends too little.
settlement_timeHow 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

bash
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:

json
{
  "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's source_amount or a swap's output.
  • Same sender. The on-chain from of the USDC transfer is return_address.
  • One transfer. A single USDC transfer on Celo (token contract 0xcebA9300f2b948710d2653dD7B07f33A8B32118C) to deposit.address. Do not split it.

These are the mistakes we see in practice:

What happenedResult
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:

javascript
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:

  1. Swap to the payer's own wallet. Swap enough to cover total_amount and 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.
  2. Send the exact deposit. Check the balance, create the payment, and send exactly deposit.amount from that same wallet to deposit.address with a plain ERC-20 transfer.
javascript
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

bash
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:

json
{
  "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.)

  • 202 with the payment still awaiting_funds: the provider accepted the cancel and has not confirmed it yet. The payment ends canceled with error_code canceled_by_integrator within a minute or two; the operation.updated webhook tells you. Do not send the USDC.
  • Canceling a payment that is already canceled answers 200 with it again.
  • 409 payment_not_cancelable: the payment is past awaiting_funds (the deposit was detected, or it already ended). The error includes the current payment. Follow it instead.
  • 409 deposit_in_flight: a USDC transfer from return_address to deposit.address is 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 same Idempotency-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_codeMeaning
canceled_by_integratorYou called POST /payments/{id}/cancel.
expired_unfundedNo deposit arrived within 30 minutes of creating the payment.
provider_canceledThe provider canceled it. Contact support@tucop.org with the id.

Follow the payment

statusMeaning
awaiting_fundsWaiting for the deposit. It stays here for about 15 minutes after the USDC land on-chain, while the provider detects them.
processingThe provider has the USDC and is sending the pesos. Usually seconds to a few minutes. Not a failure.
completedThe provider sent the pesos to the destination through Bre-B. Final.
failedThe payout failed. error_code says why (provider_<reason>). Final.
refundedThe USDC was returned to return_address. Final.
canceledCanceled before any deposit, by you or after 30 minutes without one. error_code says which. See Cancel a payment. Final.
creatingOnly 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:

PhaseTypical timestatus
USDC land on-chain, the provider has not detected them yetabout 15 minutesawaiting_funds
The provider detected the deposit and sends the pesos through Bre-Bseconds to a few minutesprocessing
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:

json
"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

CodeStatusWhen
invalid_amount400amount_cop is not { "amount": "<decimal>", "asset": "COP" } (a decimal string on GET /rates), is not positive, or has more than 2 decimals.
amount_not_settleable400amount_cop is not whole pesos.
invalid_return_address400return_address is not a 0x address.
product_not_enabled403Your consumer does not have pay.
party_not_linked403The party withdrew its consent to your app.
party_restricted403TuCOP restricted the party.
integrator_customer_mismatch403Your integrator pays from its own payments provider account, and the session is for another party.
destination_not_found404No such destination for this party and consumer.
quote_not_found404No such quote for this party and consumer.
payment_not_found404No such payment for this party and consumer.
party_not_verified409The party has not finished identity verification.
destination_not_verified409The destination is still pending or was rejected.
destination_not_confirmed409The payer has not confirmed the holder.
quote_expired409The quote expired, or the rate moved and it no longer pays the destination. Ask for a new quote.
quote_already_used409Each quote pays once. Ask for a new one.
payment_not_cancelable409Cancel: the payment is no longer awaiting_funds. The error includes payment.
deposit_in_flight409Cancel: a deposit for the payment is on-chain and about to be detected. Wait for it.
integrator_not_linked409No session was sent, and your integrator's payments provider account is not linked yet.
amount_below_minimum422Below the minimum. The error includes minimum.
pay_limit_exceeded422Over a limit. The error includes check: per_operation, integrator_day or integrator_month.
destination_not_third_party422pay pays third parties. Use withdraw for the party's own account.
return_address_required422The session has no wallet and no return_address was sent.
payment_rejected422The provider refused the payout. The quote is used; ask for a new one.
rate_unavailable502The provider has no rate right now. Retry.
provider_error502The provider did not return deposit instructions.