TuCOPRamp

Parties and sessions

Open a party session for your user, create or link their party, and follow identity verification.

A party is a real person (individual) or company (business) that TuCOP serves. There is one party per identity document, shared by every app that serves the same person or company. Your app reaches a party through a party session: proof that the person using your app controls an email or a wallet.

Every request below needs your consumer key (X-TuCOPRamp-Key or Authorization: Bearer). Requests to /parties, and to /destinations, /quotes, /payments, /limits, /withdrawals and /deposits, also need the party session in the X-TuCOPRamp-Party-Session header. The one exception is an integrator whose own payments provider account TuCOP linked: see Pay from your own payments provider account. POST /parties always needs a session.

Onboarding flow

  1. Open a party session

    Prove the person's email with a code, or their wallet with a signature. You get a session token and, when the person already has a party linked to your app, its party_id.

  2. Create or link the party

    POST /parties with the party type, the country, the legal name, the identity document and the consent your app collected. A new person or company gets a party and identity verification links (201). A person TuCOP already knows, who proved an email or wallet attached to their party, is linked to your app (200).

  3. Send the person through verification

    Open party.links.kyc and party.links.tos for the person. Both are hosted pages. When they are done, the person returns to redirect_url.

  4. Wait for approval

    Listen for party.updated, or poll GET /parties/me. The party can use products once status.kyc is approved, status.tos is accepted and the cop endorsement is approved. If links.kyc is set again after KYC is approved, the COP enablement still needs something from the person: show it to them again.

  5. Use a product

    Register the Bre-B keys the person pays with Destinations, then quote and pay with Payments.

Party sessions

A party session:

  • belongs to your consumer. Another consumer's key is refused with it (401 party_session_invalid);
  • expires one hour after it is issued. Open a new one when it expires;
  • is shown once. TuCOP stores only a hash of it.

The session endpoints exchange a one-time proof for a session, so they do not take an Idempotency-Key (a stored response would hold the session token). If a call fails, start again with a new code or nonce.

With an email code

bash
curl https://sandbox.api.ramp.tucop.xyz/sessions/email-otp \
  --request POST \
  --header "X-TuCOPRamp-Key: $TUCOPRAMP_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{ "email": "ana@example.com" }'

TuCOP emails a 6-digit code, valid 10 minutes. The answer is 202 with when the code expires, the same whether or not the email is known:

json
{ "expires_at": "2026-10-08T15:12:00.000Z" }

Then:

bash
curl https://sandbox.api.ramp.tucop.xyz/sessions/email-otp/verify \
  --request POST \
  --header "X-TuCOPRamp-Key: $TUCOPRAMP_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{ "email": "ana@example.com", "code": "493027" }'
json
{
  "token": "tps_q1Xn0d3S2m6v9bK8aP4tZr7wYc5eH1jL0uG2fN3sD8o",
  "method": "email_otp",
  "expires_at": "2026-10-08T16:05:00.000Z",
  "party_id": null
}
  • Only the newest code sent through your consumer counts. A code sent through another app does not.
  • Five wrong tries spend a code. Three codes per email every 15 minutes, and ten verify attempts per email every 15 minutes.
  • The verify call answers 201 with the session.

With a wallet signature

  1. Get the message

    POST /sessions/wallet/nonce with { "address": "0x..." }. You may also send "chain_id": 42220; it is the default, and any other chain gets 422 chain_not_supported. The answer (201) has nonce, message, chain_id and expires_at. The message names your consumer, the wallet, the chain (Celo, 42220), the nonce and its expiry (5 minutes).

  2. Sign it

    Sign message exactly as returned with EIP-191 (personal_sign). Only EOA signatures are accepted.

  3. Exchange it

    POST /sessions/wallet with { "address", "nonce", "signature" }. A nonce works once, and only for the consumer it was issued to.

json
{
  "nonce": "9f2c4e6a8b0d1f3a5c7e9b1d3f5a7c9e",
  "message": "TuCOPRamp party session\nConsumer: con_0bdy554f0x9szsmatwdnz8z6gb\nWallet: 0x1111111111111111111111111111111111111111\nChain ID: 42220\nNonce: 9f2c4e6a8b0d1f3a5c7e9b1d3f5a7c9e\nExpires: 2026-10-08T15:07:00.000Z",
  "chain_id": 42220,
  "expires_at": "2026-10-08T15:07:00.000Z"
}

At most 10 nonces per wallet address per minute (429 rate_limited_wallet).

javascript
import { privateKeyToAccount } from 'viem/accounts';

const account = privateKeyToAccount(process.env.USER_PRIVATE_KEY);
const { nonce, message } = await api.post('/sessions/wallet/nonce', { address: account.address });
const signature = await account.signMessage({ message });
const session = await api.post('/sessions/wallet', { address: account.address, nonce, signature });

With Google (TuCOP sites only)

TuCOP's own site also signs people in with Google (POST /sessions/google/nonce, then POST /sessions/google with the ID token). Google only runs TuCOP's sign-in on pages TuCOP registered, so it does not work from your app: use the email code or a wallet signature. A Google session proves the account's email, the same as an email code ("method": "google"), so a person who signed up with a code and later uses Google with the same email reaches the same party.

Using the session

bash
curl https://sandbox.api.ramp.tucop.xyz/parties/me \
  --header "X-TuCOPRamp-Key: $TUCOPRAMP_API_KEY" \
  --header "X-TuCOPRamp-Party-Session: $PARTY_SESSION"

A session acts for the party that holds its email or wallet, once that party is linked to your app. Until then, endpoints that need a party answer 403 party_required: call POST /parties.

Signing out

DELETE /sessions/current with the session in X-TuCOPRamp-Party-Session revokes it at once: later calls with it get 401 party_session_invalid. Call it whenever your user signs out, instead of only forgetting the token on your side. It answers 204 whether or not there was a live session. Without the header it answers 401 party_session_required.

bash
curl https://sandbox.api.ramp.tucop.xyz/parties \
  --request POST \
  --header "X-TuCOPRamp-Key: $TUCOPRAMP_API_KEY" \
  --header "X-TuCOPRamp-Party-Session: $PARTY_SESSION" \
  --header "Idempotency-Key: 5a0e3c2d-8f4b-4f60-9a1d-2c7e6b9f0a13" \
  --header "Content-Type: application/json" \
  --data '{
    "type": "individual",
    "country": "COL",
    "first_names": "Ana Maria",
    "last_names": "Perez Gomez",
    "nationality": "COL",
    "document_type": "CC",
    "document_number": "1017123456",
    "consent": { "version": "2026-10-08", "accepted_at": "2026-10-08T15:02:11Z" },
    "redirect_url": "https://yourapp.example/kyc/done"
  }'
json
{
  "party": {
    "id": "pty_01j9z3k8q2m4n6p8r0s2t4v6w8",
    "type": "individual",
    "country": "COL",
    "status": {
      "kyc": "not_started",
      "tos": "pending",
      "endorsements": [{ "name": "cop", "status": "pending" }]
    },
    "restricted": false,
    "links": {
      "kyc": "https://verify.example/kyc/...",
      "tos": "https://verify.example/tos/..."
    },
    "created_at": "2026-10-08T15:02:12.418Z",
    "updated_at": "2026-10-08T15:02:12.418Z"
  }
}
FieldNotes
typeindividual or business. A person is verified with hosted KYC, a company with hosted KYB.
countryISO 3166-1 alpha-3 code of the country that issued the document. Optional, defaults to COL.
first_names, last_namesIndividuals: given names and surnames, as on the document. Send both. Preferred over legal_name.
nationalityIndividuals: ISO 3166-1 alpha-3. Optional. It can differ from country: a foreigner with a Colombian cédula de extranjería has nationality VEN and country COL. An unknown code answers 400 invalid_nationality.
legal_nameA business: its registered name (required). An individual: the full legal name, at least two words, only when first_names and last_names are not sent.
document_type, document_numberA type GET /document-types lists for country and type. Dots, spaces and dashes are ignored: 1.017.123.456 and 1017123456 are the same document.
consentThe version of the consent text the person accepted in your app, and when. Recorded per app.
emailOnly for sessions opened with a wallet: the contact email for identity verification. It is not attached to the party as a proven email. Without it, a wallet session gets 422 email_required when the party has no verified email.
redirect_urlWhere verification sends the person back. Must be one of the return URLs TuCOP has on file for your app. Optional.

The answer:

  • 201: a new party. TuCOP created its verification account and returned the links.
  • 200: the party already existed and the session proved an email or wallet attached to it. It is now linked to your app. If it already had a verification account, no new one is created; its status may already be approved.

Documents per country

GET /document-types?country=COL&party_type=individual lists the identity documents a party may give, so your form does not hard-code them. country is an ISO 3166-1 alpha-3 code, required. party_type is individual (the default) or business. It needs only your consumer key.

json
{
  "country": "COL",
  "country_name": "Colombia",
  "party_type": "individual",
  "document_types": [
    { "code": "CC", "name": "Cedula de ciudadania", "validation": "local", "hint": "1 a 10 digitos" },
    { "code": "CE", "name": "Cedula de extranjeria", "validation": "local", "hint": "1 a 10 digitos" },
    { "code": "NUIP", "name": "Numero unico de identificacion personal", "validation": "local", "hint": "10 digitos, desde 1000000000" },
    { "code": "PAS", "name": "Pasaporte", "validation": "local", "hint": "5 a 20 letras y numeros" }
  ]
}

A missing or unknown country gets 400 invalid_country, and a party_type other than individual or business gets 400 invalid_party_type. The answer may be cached for an hour.

  • Colombia answers TuCOP's own types (CC, CE, NUIP, PAS for individuals, NIT for businesses) with validation: "local": the number is checked with the issuer's rule, including the NIT check digit.
  • TI (tarjeta de identidad) is not accepted for a party: it is a minor's document, and identity verification is for adults only.
  • Other countries answer the list the payments provider publishes for that country, plus the types it accepts for any non-US country. Where the issuer's format is published (for example Venezuela's RIF, Ecuador's cedula, Peru's DNI, Chile's RUT, Argentina's CUIL and DNI, Mexico's CURP, Brazil's CPF, Spain's NIF and NIE, the US SSN), the type has validation: "local" and the number is checked for length, format and check digit. The rest have validation: "format_only": only letters and digits within a length are checked. Passports are always format_only (5 to 20 letters or digits).
  • Each type has a hint describing the expected number. Show it next to the field.

POST /parties accepts the same types for the same country and type. Any other combination answers 400 invalid_document with reason: "type_not_accepted", for example a CC for a business or a NIT for an individual. For Colombia (country COL or left out), a document_type that is not a Colombian type at all (not CC, CE, TI, NUIP, NIT or PAS) answers 400 invalid_body instead, with document_type in issues.

  • A Colombian NIT may carry its check digit after a dash (900123456-8). When it does, the digit must be right, or the answer is 400 invalid_document with reason: "bad_verification_digit". The digit is not part of the NIT: 900123456-8 and 900123456 are the same company.
  • Every invalid_document answer has a reason field next to code, to branch on (never on detail): type_not_accepted, unknown_country, not_numeric, not_alphanumeric, too_short, too_long, leading_zero, bad_length, bad_format or bad_verification_digit.
  • A party from outside Colombia is verified by the payments provider's hosted KYC or KYB like any other party. Its number is checked with the rule its type lists. The same number from two countries is two different parties.

Businesses

A business party is created the same way, with type: "business" and a business document. Its links.kyc opens a hosted KYB: the person filling it in gives the company's details and its beneficial owners. While the owners are pending, status.kyc is incomplete.

json
{
  "type": "business",
  "country": "COL",
  "legal_name": "Empresa Ejemplo SAS",
  "document_type": "NIT",
  "document_number": "900123456-8",
  "consent": { "version": "2026-10-08", "accepted_at": "2026-10-08T15:02:11Z" }
}

Identity must be proven

TuCOP links an existing party to your app only when the session's email, or the session's wallet, is already attached to that party. Knowing the document number is not enough. When the session cannot prove the identity, the answer is 409 party_identity_not_proven:

  • the document belongs to a party the session's email or wallet is not attached to, or
  • the session's email or wallet already belongs to a party with a different document.

The answer is the same in both cases and never says which party holds the document. Each one is logged and reviewed by TuCOP. Ask the person to sign in with the email or wallet they used before, or contact TuCOP support with the request_id.

Wallets linked to a document in the P2P flow before party sessions existed were attached without a signature. They do not prove a party on their own.

Status

GET /parties/me returns the party, its status and the instruments your app may see. While verification is moving, TuCOP re-reads it from the provider on this call (at most every 10 seconds).

json
{
  "party": {
    "id": "pty_01j9z3k8q2m4n6p8r0s2t4v6w8",
    "type": "individual",
    "country": "COL",
    "status": {
      "kyc": "approved",
      "tos": "accepted",
      "endorsements": [{ "name": "cop", "status": "approved" }]
    },
    "restricted": false,
    "links": { "kyc": null, "tos": null },
    "created_at": "2026-10-08T15:02:12.418Z",
    "updated_at": "2026-10-08T15:20:40.512Z"
  },
  "instruments": {
    "destinations": [
      {
        "id": "dst_01j9z3k8q2m4n6p8r0s2t4v6w8",
        "ownership": "third_party",
        "status": "verified",
        "key_masked": "****4567",
        "bank": "Bancolombia",
        "created_at": "2026-10-08T15:01:55.402Z"
      }
    ]
  }
}
FieldValues
status.kycnot_started, incomplete (the person has to act in links.kyc), under_review, approved, rejected, suspended
status.tospending, accepted
status.endorsements[]{ "name": "cop", "status": "pending" | "approved" | "revoked" }. cop clears the party for Colombian pesos over Bre-B.
restrictedSet by TuCOP. A restricted party gets no instruments.
links.kyc, links.tosHosted pages. null once there is nothing left to do. links.kyc can come back after status.kyc is approved (see below).
instruments.destinationsDestinations your app created for the party, and the party's own (first-party) destinations, which are shared between apps. Oldest first, without deleted ones. Empty while the party is restricted. Read the full destination with GET /destinations/{id}.

When links.kyc comes back after KYC is approved

Sometimes status.kyc is approved and status.tos is accepted, but the cop endorsement stays pending because the COP enablement still needs something from the person (for example, a person who already had an account with the payments provider). TuCOP then sets links.kyc again, to a hosted page where the person completes that step, while links.tos stays null.

Whenever links.kyc is not null, show it to the person, even if you already took them through KYC. Once the cop endorsement is approved, links.kyc goes back to null. The link is refreshed by TuCOP, so always use the one from the latest response rather than one you stored.

Webhooks

party.updated is sent to every app linked to the party when its verification status, its endorsements or its restriction change. data:

json
{
  "party_id": "pty_01j9z3k8q2m4n6p8r0s2t4v6w8",
  "status": {
    "kyc": "approved",
    "tos": "accepted",
    "endorsements": [{ "name": "cop", "status": "approved" }]
  },
  "restricted": false,
  "updated_at": "2026-10-08T15:20:40.512Z"
}

Events can arrive out of order. Keep the one with the latest updated_at, or read GET /parties/me. See Webhooks.

Error codes

CodeStatusWhen
invalid_code401The email code is wrong, used or expired.
nonce_invalid401The wallet or Google nonce is unknown, used, expired or was issued to another consumer.
signature_invalid401The signature does not match the message for that wallet.
chain_not_supported422A wallet nonce for a chain other than Celo (42220).
google_sign_in_unavailable503Google sign-in is not set up on this deployment. It works only on TuCOP's own sites.
id_token_invalid401The Google ID token is not valid.
email_not_verified401Google has not verified the account's email.
party_session_required401X-TuCOPRamp-Party-Session is missing, and TuCOP has not linked a payments provider account to your integrator. POST /parties always needs a session, even then.
party_session_invalid401The session is unknown, expired, revoked or belongs to another consumer.
party_required403The session has no party linked to your app yet. Call POST /parties.
invalid_country400country is not an ISO 3166-1 alpha-3 code (POST /parties, GET /document-types).
invalid_party_type400GET /document-types: party_type is not individual or business.
invalid_body400The body failed validation, for example a missing field or, for Colombia, an unknown document_type. See issues.
invalid_document400The document type is not accepted for the country and party type, or the number does not fit its type.
invalid_name400An individual's legal name is not at least two words, or a business name is empty.
invalid_nationality400nationality is not an ISO 3166-1 alpha-3 code.
invalid_consent400consent.accepted_at is in the future.
email_required422A wallet session creating a party with no email to send verification to.
redirect_url_not_allowed422redirect_url is not one of your return URLs.
party_identity_not_proven409The session cannot prove the identity that holds the document. See above.
provider_unavailable502The verification provider failed. Retry with the same Idempotency-Key.
email_not_sent502The email with the code could not be sent. Retry later.
rate_limited_email429Too many codes or attempts for this email. See Retry-After.
rate_limited_wallet429More than 10 wallet nonces for this address in a minute. See Retry-After.
rate_limited_ip429Your consumer is a trusted relay and one end user's address went over its session limits. See Retry-After.
rate_limited_party_session429Too many POST /parties with this session (10 per 15 minutes). See Retry-After.
rate_limited_consumer429Too many session requests for your whole consumer. See Retry-After.