Destinations
Register Bre-B keys for a party, wait for the Bre-B directory, and confirm the holder.
A destination is a Bre-B key registered for a party. TuCOP registers it with its payment provider and checks it against the Bre-B directory, which returns the account holder's name, bank and the last 4 digits of their identity document.
There are two kinds:
ownership | What it is | Product needed | Passes when |
|---|---|---|---|
third_party | Someone the party pays. | pay | With owner_name: the directory reports a match. Without it (a key lookup): the directory resolves the key to a holder. |
first_party | The party's own account. | withdraw | The directory reports a match, the holder is the party's verified (KYC) name, and the holder document is the party's. Not available for a party that stands for a payments provider account TuCOP linked to your integrator (409 first_party_not_available). |
Every request needs your consumer key (X-TuCOPRamp-Key or Authorization: Bearer) and a
party session in the X-TuCOPRamp-Party-Session header. A session with
no party linked to your app yet gets 403 party_required. To register a key, the party must have
finished identity verification (GET /parties/me), or the request gets
409 party_not_verified, and your consumer needs the product for that kind of destination.
The Bre-B key is never stored. TuCOP keeps a hash of it (to find it again) and the masked key
(****4567).
Register a key
curl https://sandbox.api.ramp.tucop.xyz/destinations \
--request POST \
--header "X-TuCOPRamp-Key: $TUCOPRAMP_API_KEY" \
--header "X-TuCOPRamp-Party-Session: $PARTY_SESSION" \
--header "Idempotency-Key: 5f0c7a1e-3b8d-4f42-9a6e-1c2d3e4f5a6b" \
--header "Content-Type: application/json" \
--data '{ "ownership": "third_party", "bre_b_key": "3001234567", "owner_name": "Carlos Ruiz" }'
owner_nameis optional forthird_party: the holder name as the payer typed it, at least two words. Leave it out to look the key up and get the holder from the directory. Forfirst_partyit is ignored and the party's verified name is used.- If the party already has this key (the same key as
first_party, or the same key added by your consumer asthird_party), the existing destination comes back with200. To pay the same key again, reuse itsdestination_id(GET /destinationslists them); you do not need to register it again. - If the key was registered at the provider outside your app (for the same customer), TuCOP reuses
that account and the answer is
202as usual. - Otherwise the answer is
202withstatus: "pending".
Key formats
Send the key in the format of its type, as Banco de la República defines it:
| Type | Format | Example |
|---|---|---|
| Mobile number | 10 digits, starts with 3, no country code | 3001234567 |
| Document number | Up to 18 letters or digits, without dots, dashes or spaces | 1023456789 |
Up to 30 characters before the @ and 61 after it | ana@example.com | |
| Alphanumeric key | @ followed by 5 to 20 letters or digits, without accents or ñ | @anaperez25 |
| Merchant code | 10 digits starting with 00 | 0012345678 |
How your app gets the key into that format is up to you: add the @ to an alphanumeric key, drop
+57, or ask the user which type of key it is. TuCOP checks the key again either way:
- Bre-B does not tell upper from lower case, so TuCOP writes the key in lower case.
@AnaPerez25and@anaperez25are the same key and the same destination. - A mobile number sent with
+57is taken without it. - A key that fits none of the formats gets
400 invalid_bre_b_keyat once, without a directory check. TuCOP never adds an@: letters without one are read as a document number.
Register a key when the user submits it, not while they type: every registration is checked against the Bre-B directory.
How many keys
By default there is no limit on how many destinations a party has. A person can register several of
their own Bre-B keys (first_party), each with its own
withdrawal address; first-party destinations belong to the
party and are shared by every app linked to it.
If you want a cap, set it yourself in the integrator console: Maximo de llaves propias por persona
on the home page (and on the Sandbox tab for sandbox), from 1 to 1000, or empty for no limit. There is
none until you set one. TuCOP may also set a maximum for your integrator; the console shows it
(Tope de TuCOP) and does not accept a limit of yours above it. When both are set, the lower one
applies, and the error detail says which. It counts only the active first-party keys your app registered for that
person: keys another app registered do not count, and neither do deleted or rejected ones. Once the
person has that many, registering another first-party key answers 409 destination_limit_reached;
delete one of them first. A key the person already has active is not limited: registering it again
returns that destination with 200 as usual. Registering a deleted key again counts like a new one.
A new limit applies to the next registration; keys already registered stay.
third_party destinations have no count limit either. GET /destinations returns at most 100 of them;
reuse the destination_id of a key you already registered instead of registering it again.
{
"id": "dst_01j9z3k8q2m4n6p8r0s2t4v6w8",
"ownership": "third_party",
"rail": "bre_b",
"status": "pending",
"key_masked": "****4567",
"owner_name": "Carlos Ruiz",
"holder": null,
"rejection_reason": null,
"verified_at": null,
"confirmed_at": null,
"active": true,
"disabled_at": null,
"created_at": "2026-10-08T15:01:55.402Z",
"updated_at": "2026-10-08T15:01:55.402Z"
}
Look up a key
The payer often knows only the Bre-B key. Send ownership: "third_party" and bre_b_key, without
owner_name, and TuCOP returns the holder the Bre-B directory has for that key:
curl https://sandbox.api.ramp.tucop.xyz/destinations \
--request POST \
--header "X-TuCOPRamp-Key: $TUCOPRAMP_API_KEY" \
--header "X-TuCOPRamp-Party-Session: $PARTY_SESSION" \
--header "Idempotency-Key: 9b2e4c6a-1d3f-4a5b-8c7d-2e4f6a8b0c1d" \
--header "Content-Type: application/json" \
--data '{ "ownership": "third_party", "bre_b_key": "3001234567" }'
- The answer is
202withstatus: "pending"andowner_name: null. - Wait for the directory as usual: the
destination.verifiedwebhook, or pollGET /destinations/{id}. It takes about a minute. - Once
verified,holder.name,holder.bankandholder.document_last4say who holds the key. Show them to the payer. - If the payer says it is the right person, confirm the holder. Payments to
the destination need it, exactly as for a destination registered with
owner_name.
A key lookup is verified when the directory finds a holder for the key. It is rejected with
directory_no_match when the directory has no account for the key, with
directory_unavailable when the directory fails to answer (also after TuCOP asked again), and with
verification_timeout when it does not answer in time. owner_name stays null on the
destination; the holder is always in holder.
Sending owner_name is optional. When you send it, it is also checked loosely by the directory, as
before: the destination passes only when the directory reports a match. A key you already
registered (with or without owner_name) comes back with 200 as it is; it is not looked up
again. One exception: when the key was looked up without a name and you now send owner_name, the
name is checked against the holder the lookup found, and a name that does not match answers
409 holder_name_mismatch (with destination_id) instead of the destination.
Wait for the directory
The Bre-B directory answers in about a minute (48 to 55 seconds in production). TuCOP does not hold the request open. You learn the result in one of two ways:
- the
destination.verifiedordestination.rejectedwebhook, or - polling
GET /destinations/{id}every 5 to 10 seconds untilstatusis no longerpending. Do not stop at a fixed time of your own: when TuCOP asks the directory again (see below), a destination can staypendingfor up to about 10 minutes.
Then store the destination as Keep your copy in sync says.
A verified destination shows what the directory returned:
{
"id": "dst_01j9z3k8q2m4n6p8r0s2t4v6w8",
"status": "verified",
"holder": { "name": "CARLOS RUIZ", "bank": "Bancolombia", "document_last4": "4821" },
"verified_at": "2026-10-08T15:02:51.120Z"
}
A rejected destination has a rejection_reason and no holder data. TuCOP deletes the account at
the provider, and the same key can be registered again.
rejection_reason | Meaning |
|---|---|
directory_no_match | The directory has no account for this key, or it does not match the owner_name sent. For a key lookup: no holder was found for the key. |
holder_name_mismatch | first_party only: the holder is not the party. |
holder_document_mismatch | first_party only: the holder document is not the party's. |
verification_timeout | The directory did not answer within 5 minutes. Try again later. |
directory_unavailable | The directory failed to answer (for example "Transaction rejected"), and failed again when TuCOP asked a second time. The key may well exist: register it again later. |
When the directory's first answer is a passing failure rather than "no account for this key", TuCOP
asks it again once, on its own: the destination stays pending for about another minute. Do not
retry a directory_no_match yourself; it means the key does not exist. Retry only
directory_unavailable and verification_timeout, a few minutes later.
Confirm the holder
Before a payment to a third_party destination, show the payer holder.name,
holder.bank and holder.document_last4, and ask them to confirm this is the account they mean to
pay. Then record it:
curl https://sandbox.api.ramp.tucop.xyz/destinations/dst_01j9z3k8q2m4n6p8r0s2t4v6w8/confirm \
--request POST \
--header "X-TuCOPRamp-Key: $TUCOPRAMP_API_KEY" \
--header "X-TuCOPRamp-Party-Session: $PARTY_SESSION" \
--header "Idempotency-Key: 0e6d1f2a-7c4b-4b9e-8a31-5d2c9f0e7a14"
The answer is the destination with confirmed_at set. A destination that is not verified gets
409 destination_not_verified. Confirming again changes nothing.
When a destination can be paid
| Step | Needs |
|---|---|
POST /quotes | status: "verified" |
POST /payments | status: "verified" and confirmed_at set, or 409 destination_not_confirmed |
There is no confirmed status: a destination is confirmed when confirmed_at is set, and it stays
confirmed for every later payment. TuCOP is the source of truth. Before you quote, read the
destination with GET /destinations/{id} and use its status, confirmed_at and holder
rather than a copy you saved earlier; when they disagree, update your copy from TuCOP's, and never
overwrite a stored holder with an empty value.
Keep your copy in sync
If you keep destinations in your own database, update that copy from TuCOP every time it changes.
A copy that still says pending after TuCOP verified the key is the most common integration bug.
- The
202fromPOST /destinationsis never the final state: it always sayspending. Store the destination again once it leavespending. - Store the whole destination every time you read it with
GET /destinations/{id}, not only the one from registration.status,holder,verified_atandconfirmed_atchange after you register the key. - With webhooks: on
destination.verifiedordestination.rejected, readGET /destinations/{id}and store what it returns. The event is thin on purpose: it has noholder. - Without webhooks: poll
GET /destinations/{id}every 5 to 10 seconds untilstatusisverifiedorrejected, and store that answer. It usually takes about a minute, and up to about 10 minutes when TuCOP asks the directory again. Do not give up earlier and leave the destinationpendingin your copy. - If your process restarts while some destinations are
pendingin your copy, read each of them again, or readGET /eventsfrom the lastnext_afteryou stored. The feed has everydestination.*event even when you have no webhook endpoints. - After the payer confirms the holder, store the destination from the confirm response: it has
confirmed_atset. - Send your own
X-Request-Id(a UUID) on each request, or store the one TuCOP returns. When you ask TuCOP about a request, give that id. See Request ids.
List and delete
GET /destinationslists the party's destinations your consumer can see: the ones it created, and the party'sfirst_partydestinations whoever created them. Newest first, 100 per page (limitfrom 1 to 100). The answer is{ "data": [...], "has_more": true }: whenhas_moreistrue, ask for the next page withstarting_afterset to the lastidyou got. Deleted destinations are left out. Add?include_disabled=trueto get them too.- If TuCOP linked your own payments provider account, every key your users register is under that
one account, so this list holds all of them together. Keep which
destination_idbelongs to which of your users, and show each user only theirs. GET /destinations/{id}returns a destination also after it was deleted, so you can still show it.DELETE /destinations/{id}deletes one and answers204. Deleting it again also answers204. A destination with a payment in progress, or with an active withdrawal address, gets409 destination_in_use: disable the withdrawal first (DELETE /withdrawals/{id}).
Each destination has status (the directory check: pending, verified, rejected) and,
separately, active and disabled_at. A deleted destination keeps its status and has
active: false and disabled_at set. It cannot be paid or withdrawn to. To use the key again,
register it again with POST /destinations.
TuCOP also deletes the account at the provider, except when the destination ever had a
withdrawal address. The provider cannot turn a withdrawal
address off: USDC sent to a disabled address is still paid out to its key. The account stays at the
provider so those funds still reach the person. Registering such a key again brings the same
destination back (200, same destination_id, active: true, still verified), and its
withdrawal address can be re-enabled. Any other deleted key registered again gets a new
destination_id and goes through the directory check again.
Another consumer's third_party destinations are never visible to you: they get
404 destination_not_found.
Errors
| Code | Status | When |
|---|---|---|
party_session_required | 401 | X-TuCOPRamp-Party-Session is missing, and TuCOP has not linked a payments provider account to your integrator. |
party_session_invalid | 401 | The session is unknown, expired, revoked or belongs to another consumer. |
integrator_not_linked | 409 | No session was sent, and your integrator's payments provider account is not linked yet. |
party_required | 403 | The session has no party linked to your app yet. Call POST /parties. |
party_not_linked | 403 | The party withdrew its consent to your app while the session was open. |
party_restricted | 403 | TuCOP restricted the party. |
product_not_enabled | 403 | Registering a key: your consumer does not have pay (third party) or withdraw (first party). |
integrator_customer_mismatch | 403 | Registering a third-party key: your integrator pays from its own payments provider account, and the session is for another party. |
invalid_body | 400 | The body failed validation, for example an empty owner_name or an unknown field. See issues. |
invalid_bre_b_key | 400 | The key fits none of the Bre-B key formats. |
invalid_owner_name | 400 | owner_name was sent and is not at least two words. Leave it out to look the key up instead. |
party_not_verified | 409 | The party has not finished identity verification. |
kyc_name_missing | 409 | first_party: the verified name is not available yet. Retry later. |
first_party_not_available | 409 | first_party: The party stands for a payments provider account TuCOP linked to your integrator. It has no identity document to match a first-party key. Use Payments for third-party keys. |
holder_name_mismatch | 409 | third_party: the key was already looked up for this party and its holder does not match the owner_name sent. Show the payer holder.name from that destination. |
destination_conflict | 409 | Another request is registering the same key right now. Retry. |
destination_limit_reached | 409 | first_party: the person already has as many active first-party keys registered by your app as allowed (the lower of the limit you set in the integrator console and TuCOP's maximum for your integrator). Delete one first. See How many keys. |
destination_already_registered | 409 | The key is already registered on this payments provider account, created outside this app, and could not be matched to exactly one. Nothing was created. |
destination_invalid | 422 | The provider refused the key. Nothing was created. |
destination_not_found | 404 | No such destination for this party and consumer. |
destination_not_verified | 409 | Confirming a destination that is not verified. |
destination_in_use | 409 | Deleting a destination with a payment in progress or an active withdrawal address. Disable the withdrawal first. |
invalid_query | 400 | include_disabled is not true or false. |