QvaPay API
Withdraw

Ejemplos por Método

Cómo armar el objeto details de POST /withdraw para cripto, USD CASH, BANK_CUP, Zelle y transferencia bancaria, y de dónde sacar cada dato.

details es el formulario del método de pago: los datos que QvaPay necesita para entregar el dinero (una wallet, una tarjeta, la dirección donde se entrega el efectivo…). Cada moneda tiene su propio formulario, descrito en su campo working_data, y details tiene que coincidir con él clave por clave.

Esta página explica las reglas, de dónde sale cada dato y trae un ejemplo completo por método. El resto de parámetros de POST /withdraw (pin, webhook, idempotency_key, compliance…) está en Listar y Crear Retiros.

Reglas de details

  1. Las claves son exactamente los name del working_data de la moneda, con sus mayúsculas, tildes y espacios. "Nro de Celular" (USDCASH) y "Nro de celular" (BANK_CUP) son claves distintas, igual que "Dirección" (USDCASH) y "Direccion" (BANK).
  2. Van todas. Cada campo del working_data tiene que llegar con un valor no vacío.
  3. Ninguna clave extra. Una clave que no está en el working_data (zone, wallet en minúscula, phone…) rechaza el retiro entero.
  4. Todos los valores son string, también el carné, el teléfono y el número de tarjeta. En PHP, Python o JS no los mandes como número.
  5. pay_method es el tick de la moneda (USDT, USDTBSC, USDCASH, BANK_CUP…), no su nombre.

De dónde sacar cada dato

DatoFuente
pay_methodCampo tick del coin en GET /coins/v2?enabled_out=1 (solo monedas con retiro habilitado)
Claves de detailsworking_data[].name del mismo coin
Tipo de cada campoworking_data[].type: text, phone (solo dígitos, espacios, guiones, paréntesis y + inicial), email, number o select
Valores de un selectworking_data[].options[].value, salvo la provincia del efectivo por zonas (ver USD CASH)
Provincia y municipio del efectivoCampo cash_zones del coin, o GET /withdraw/cash-zones?coin=USDCASH
Datos del beneficiario (nombre, carné, teléfono, dirección, tarjeta, wallet)Tu propio formulario o tu sistema: es lo que tu cliente te dice sobre quién recibe
Métodos guardados del usuarioGET /user/payment-methods?coin=… (solo Bearer): su details ya trae las claves del formulario, pero comprueba que estén todas antes de reenviarlo; hay métodos viejos incompletos
Monto mínimomin_out del coin; en efectivo, además, min_usd de la zona sobre lo que se entrega

Lee el working_data en tiempo de ejecución en lugar de copiarlo a tu código: los formularios se editan desde el panel de QvaPay y pueden cambiar. GET /coins/v2 es público y se puede cachear unos minutos (la propia respuesta trae Cache-Control: max-age=300).

const coins = await fetch('https://api.qvapay.com/coins/v2?enabled_out=1').then(r => r.json())
const coin = coins.find(c => c.tick === 'USDCASH')

const keys = coin.working_data.map(field => field.name)
// ["Nombre y Apellidos", "Carnet de Identidad", "Nro de Celular", "Dirección", "Municipio", "Provincia"]

Formularios de los métodos más usados

Los formularios vigentes el 20 de octubre de 2026, como referencia. La fuente de verdad sigue siendo el working_data de cada coin.

pay_methodClaves de detailsMínimo (min_out)
USDT, USDTBSC, USDCSOL y demás criptoWalletSegún la red (USDT TRC20: $2)
BTCLNWallet (factura BOLT11 o Lightning Address)$2
USDCASHNombre y Apellidos, Carnet de Identidad, Nro de Celular, Dirección, Municipio, Provincia$50
BANK_CUP, BANK_MLC, CLASICA, BANDECPREPAGONombre y Apellidos, Nro de tarjeta, Nro de celular$1
ZELLENombre y Apellidos, Correo o Celular$50
BANK (transferencia en USD)Nombre y Apellidos, Nombre del Banco, Nro de Cuenta, Nro de Ruta o ACH, Nro de telefono, Direccion$100

En todos los ejemplos de abajo la autenticación es con credenciales de App (app-id + app-secret), que no piden pin. Con Bearer Token añade "pin" al body.


Cripto (USDT TRC20, USDT BSC, USDC Solana…)

El formulario es un único campo, Wallet. La dirección se valida contra la red de la moneda: una dirección TRON (T…) en USDTBSC, o una 0x… en USDT (TRC20), responde 400.

curl -X POST https://api.qvapay.com/withdraw \
  -H "Content-Type: application/json" \
  -H "app-id: {tu-app-id}" \
  -H "app-secret: {tu-app-secret}" \
  -d '{
    "pay_method": "USDT",
    "amount": 100,
    "details": {
      "Wallet": "TXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    },
    "note": "Pedido 4521",
    "webhook": "https://tu-dominio.com/webhooks/qvapay/withdraw",
    "idempotency_key": "pedido-4521",
    "compliance": {
      "purposeCode": "W-1",
      "attestations": {
        "notProhibitedOfficial": true,
        "notProhibitedPartyMember": true,
        "notRestrictedList": true,
        "lawfulPurpose": true
      },
      "purposeNote": "Billetera personal",
      "language": "es"
    }
  }'

W-1 declara que la wallet es de autocustodia del propio titular de la cuenta. Mándalo solo cuando sea cierto. Si pagas a la wallet de un tercero (por ejemplo, un cliente tuyo), no envíes W-1 y declara "dest_label": "THIRD_PARTY". Ver Cumplimiento.


USD CASH (efectivo en Cuba)

USD CASH entrega dólares en efectivo en casi todo el país, con un precio y un plazo que dependen del municipio. El formulario tiene seis campos:

ClaveQué vaEjemplo
Nombre y ApellidosNombre completo de quien recibe el efectivoYanelis Pérez Rodríguez
Carnet de IdentidadCarné de identidad de quien recibe (11 dígitos)85010112345
Nro de CelularTeléfono de quien recibe, para coordinar la entrega. C mayúscula+5355512345
DirecciónDirección de entrega: calle, número, entre calles, repartoCalle Martí #123 e/ Céspedes y Maceo, Reparto Centro
Provinciaprovince_name (o province_code) de una zona del tarifarioVilla Clara
Municipiomunicipality de esa misma zonaSanta Clara

No hay clave zone. QvaPay identifica la zona a partir de Provincia + Municipio. Tampoco uses las options del campo Provincia del working_data (La Habana / Santa Clara): vienen del formulario antiguo. Con el efectivo por zonas, la lista válida es la del tarifario (16 provincias y 131 municipios hoy).

1. Cargar el tarifario y elegir la zona

GET /withdraw/cash-zones?coin=USDCASH es público. El mismo contenido viene en el campo cash_zones del coin en GET /coins/v2. QvaPay compara sin tildes ni mayúsculas, pero lo más seguro es enviar los nombres tal como vienen en el tarifario.

const norm = (s) => String(s ?? '')
  .normalize('NFD').replace(/[̀-ͯ]/g, '')
  .toLowerCase().replace(/\s+/g, ' ').trim()

const { margin_pct, zones } = await fetch('https://api.qvapay.com/withdraw/cash-zones?coin=USDCASH').then(r => r.json())

// Provincia y municipio de la dirección de tu cliente
function findZone(province, municipality) {
  return zones.find(z =>
    (norm(z.province_name) === norm(province) || norm(z.province_code) === norm(province)) &&
    norm(z.municipality) === norm(municipality)
  ) ?? null
}

const zone = findZone('Villa Clara', 'Santa Clara')
if (!zone) throw new Error('No hay entrega de efectivo en ese municipio')

Si guardas las direcciones de tus clientes con un campo libre, conviene que tu formulario ofrezca primero la provincia y después el municipio a partir de este tarifario, en vez de texto libre. Así cada dirección cae en una zona válida.

2. Calcular cuánto se entrega

amount es lo que se debita del saldo (en USD, fees incluidos). Lo que recibe el beneficiario es amount menos el fee de la moneda y el cargo de la zona:

  • Fee de la moneda: fee_out / fee_out_fixed del coin (ver Cómo funciona fee_out_fixed).
  • Cargo de la zona: max(amount × provider_pct / 100, small_flat_usd) + amount × margen / 100, donde el margen es el margin_pct de la zona o, si es null, el margin_pct global de la respuesta.
const zoneMargin = zone.margin_pct ?? margin_pct
const zoneCharge = Math.max(amount * zone.provider_pct / 100, zone.small_flat_usd ?? 0) + amount * zoneMargin / 100

Ejemplo con las tarifas del 20 de octubre de 2026: retirar $200 a Santa Clara cuesta $0 de fee de la moneda (por encima de $99.99 el fee_out de USDCASH es 0 %) más max(6, 4) + 2 = $8 de zona, y se entregan $192. Lo entregado tiene que ser al menos el min_usd de la zona ($20 en Santa Clara). El cálculo de tu lado sirve para mostrarlo antes de enviar; el valor que manda es el de la respuesta (fee_to_apply y receive_amount).

3. Crear el retiro

curl -X POST https://api.qvapay.com/withdraw \
  -H "Content-Type: application/json" \
  -H "app-id: {tu-app-id}" \
  -H "app-secret: {tu-app-secret}" \
  -d '{
    "pay_method": "USDCASH",
    "amount": 200,
    "details": {
      "Nombre y Apellidos": "Yanelis Pérez Rodríguez",
      "Carnet de Identidad": "85010112345",
      "Nro de Celular": "+5355512345",
      "Dirección": "Calle Martí #123 e/ Céspedes y Maceo, Reparto Centro",
      "Municipio": "Santa Clara",
      "Provincia": "Villa Clara"
    },
    "note": "Pedido 4521",
    "webhook": "https://tu-dominio.com/webhooks/qvapay/withdraw",
    "idempotency_key": "pedido-4521",
    "compliance": {
      "purposeCode": "P-1",
      "attestations": {
        "notProhibitedOfficial": true,
        "notProhibitedPartyMember": true,
        "notRestrictedList": true,
        "lawfulPurpose": true
      },
      "purposeNote": "Ayuda familiar",
      "language": "es"
    }
  }'

El mismo body en PHP (Symfony HttpClient):

$payload = [
    'pay_method' => 'USDCASH',
    'amount' => 200,
    'details' => [
        'Nombre y Apellidos' => 'Yanelis Pérez Rodríguez',
        'Carnet de Identidad' => '85010112345', // string, no int
        'Nro de Celular' => '+5355512345',      // C mayúscula
        'Dirección' => 'Calle Martí #123 e/ Céspedes y Maceo, Reparto Centro',
        'Municipio' => 'Santa Clara',           // municipality del tarifario
        'Provincia' => 'Villa Clara',           // province_name del tarifario
    ],
    'note' => 'Pedido 4521',
    'webhook' => 'https://tu-dominio.com/webhooks/qvapay/withdraw',
    'idempotency_key' => 'pedido-4521',
    'compliance' => [
        'purposeCode' => 'P-1',
        'attestations' => [
            'notProhibitedOfficial' => true,
            'notProhibitedPartyMember' => true,
            'notRestrictedList' => true,
            'lawfulPurpose' => true,
        ],
        'purposeNote' => 'Ayuda familiar',
        'language' => 'es',
    ],
];

$response = $client->request('POST', 'https://api.qvapay.com/withdraw', [
    'headers' => ['app-id' => $appId, 'app-secret' => $appSecret],
    'json' => $payload,
]);

Respuesta:

{
  "result": "OK",
  "data": {
    "withdraw_id": 123456,
    "transaction_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "receive_amount": 192,
    "receive_amount_coin": 192,
    "fee_to_apply": 8,
    "amount": 200,
    "coin": "USDCASH"
  }
}

El plazo de entrega de cada zona está en eta_min_hours / eta_max_hours. El estado del retiro llega al webhook y se puede consultar con GET /withdraw/:id.

Error frecuente: reutilizar el formulario de BANK_CUP para USD CASH. En BANK_CUP la clave es Nro de celular (c minúscula) y no hay carné, dirección, provincia ni municipio. En USD CASH, una clave de más (zone, Nro de celular) o una de menos rechaza el retiro.

USD CASH es entrega de valor en Cuba: el purposeCode va de P-1 a P-4 (remesa familiar, sector privado, donativo o telecomunicaciones), nunca W-1.


BANK_CUP (tarjeta en CUP), BANK_MLC, CLASICA y BANDECPREPAGO

Los cuatro métodos de tarjeta cubana comparten formulario:

ClaveQué vaEjemplo
Nombre y ApellidosTitular de la tarjetaYanelis Pérez Rodríguez
Nro de tarjeta16 dígitos, como string. Los espacios y guiones se quitan9227 0699 1234 5678
Nro de celularCelular del titular de la tarjeta. c minúscula+5355512345

La tarjeta tiene que ser del tipo que paga ese método (una tarjeta MLC no entra por BANK_CUP, una Clásica va por CLASICA). La tabla de prefijos y los motivos de rechazo de INVALID_CARD_NUMBER están en las notas de POST /withdraw.

curl -X POST https://api.qvapay.com/withdraw \
  -H "Content-Type: application/json" \
  -H "app-id: {tu-app-id}" \
  -H "app-secret: {tu-app-secret}" \
  -d '{
    "pay_method": "BANK_CUP",
    "amount": 50,
    "details": {
      "Nombre y Apellidos": "Yanelis Pérez Rodríguez",
      "Nro de tarjeta": "9227 0699 1234 5678",
      "Nro de celular": "+5355512345"
    },
    "note": "Pedido 4522",
    "idempotency_key": "pedido-4522",
    "compliance": {
      "purposeCode": "P-1",
      "attestations": {
        "notProhibitedOfficial": true,
        "notProhibitedPartyMember": true,
        "notRestrictedList": true,
        "lawfulPurpose": true
      },
      "language": "es"
    }
  }'

El número de tarjeta del ejemplo pasa la validación de formato pero no existe; usa siempre el de tu beneficiario.

Estos formularios no tienen campo de dirección. Un retiro de $3,000 o más exige la dirección de calle del beneficiario (Travel Rule), así que por estos métodos responde 400 TRAVEL_RULE_RECIPIENT_ADDRESS.


Zelle

{
  "pay_method": "ZELLE",
  "amount": 100,
  "details": {
    "Nombre y Apellidos": "John Smith",
    "Correo o Celular": "[email protected]"
  },
  "idempotency_key": "pedido-4523"
}

Correo o Celular es el correo o el teléfono registrado en Zelle por quien recibe.


Transferencia bancaria en USD (BANK)

{
  "pay_method": "BANK",
  "amount": 500,
  "details": {
    "Nombre y Apellidos": "Acme Supplies LLC",
    "Nombre del Banco": "Bank of America",
    "Nro de Cuenta": "000123456789",
    "Nro de Ruta o ACH": "026009593",
    "Nro de telefono": "+13055551234",
    "Direccion": "100 Main St, Miami, FL 33101"
  },
  "idempotency_key": "pedido-4524"
}

Direccion va sin tilde en este formulario. A partir de $3,000 el envío es multipart/form-data con el JSON en payload y la factura en PDF en invoice (ver Otros controles regulatorios).


Errores frecuentes con details

RespuestaCausaQué revisar
400 Método de pago inválidopay_method no es el tick de una moneda con retiro habilitadoUsa el tick de GET /coins/v2?enabled_out=1 (USDT, no tron_usdt)
400 Los datos de retiro contienen campos no válidosHay una clave que no está en el working_dataClaves extra (zone, wallet), o mal escritas (Nro de celular en USDCASH, Dirección en BANK)
400 Los datos de retiro deben estar completosFalta un campo del formulario, o va vacíoQue estén todas las claves del working_data, con valor
400 El campo … debe ser un teléfono válidoUn campo phone con letras u otros símbolosSolo dígitos, espacios, guiones, paréntesis y + inicial
400 El valor "…" no está disponible para …Un select con un valor fuera de sus optionsEl valor exacto de options[].value
400 CASH_ZONE_UNAVAILABLEProvincia + Municipio no forman una zona del tarifarioprovince_name y municipality de la misma zona
400 CASH_BELOW_MINLo que se entrega queda por debajo del min_usd de la zonaSube amount
400 INVALID_CARD_NUMBERTarjeta mal escrita o de otro métodoreason y expected_method de la respuesta
400 TRAVEL_RULE_RECIPIENT_ADDRESSRetiro de $3,000 o más sin dirección de calle del beneficiarioEl método necesita un campo de dirección con calle y número