Cómo enviar una transferencia cuenta a cuenta (A2A)


Mueve dinero desde tu wallet hacia una cuenta bancaria específica en ACH en línea (antes Transfiya) creando una intent con el esquema a2a. A diferencia de una dispersión B2P, el destino es la cuenta misma, así que no hay una dirección que resolver antes: digitas el número de cuenta y envías.


Prerrequisitos

Antes de empezar:

  • Leíste la guía de Primeros pasos y tienes configurados tu Wallet, Domain y Autenticación.
  • Tienes un par de llaves de firma válido para autorizar transacciones.
  • Conoces el número de cuenta de destino, su tipo de cuenta y el código de entidad del participante receptor, además del tipo y número de documento del titular.

Flujo


Paso 1: Construye la referencia de la cuenta

El destino de la intent es la cuenta receptora, escrita como accountType:accountNumber@transfiya:

svgs:107311223300009@transfiya

El tipo de cuenta va en minúsculas y debe coincidir con el tipo real de la cuenta en el banco:

CódigoTipo de cuenta
svgsCuenta de ahorros
caccCuenta corriente
dbmoDepósito de bajo monto
dordDepósito ordinario
dbmiDepósito de bajo monto inclusivo

El handle siempre termina en @transfiya, el wallet del riel, igual que las direcciones que usa una dispersión B2P. El participante receptor no va en el handle: viaja en el campo entityCode del destino.

En este flujo no hay resolución de direcciones, así que un número de cuenta equivocado no se detecta antes de enviar. El banco receptor valida la cuenta cuando recibe la transferencia y la rechaza si la cuenta no existe, lo que el riel reporta como bridge.account-not-found. Ante cualquier rechazo los fondos regresan al origen.


Paso 2: Obtén el código de entidad del participante

La lista de participantes está disponible a través del endpoint List Anchors con el filtro kind=a2a. Requiere un token JWT Bearer en el header Authorization.

GET https://<ledger-url>/api/v2/anchors?kind=a2a
Authorization: Bearer <jwt>
x-ledger: <ledger-handle>

Respuesta:

{
    "data": [
        {
            "data": {
                "handle": "<participant-1>",
                "target": "a2a",
                "custom": {
                    "entityCode": "<participant-1-entity-code>",
                    "entityName": "<PARTICIPANT-1>"
                }
            },
            "hash": "<hash>",
            "meta": { "proofs": ["..."] }
        },
        {
            "data": {
                "handle": "<participant-2>",
                "target": "a2a",
                "custom": {
                    "entityCode": "<participant-2-entity-code>",
                    "entityName": "<PARTICIPANT-2>"
                }
            },
            "hash": "<hash>",
            "meta": { "proofs": ["..."] }
        },
        {
            "data": {
                "handle": "<participant-3>",
                "target": "a2a",
                "custom": {
                    "entityCode": "<participant-3-entity-code>",
                    "entityName": "<PARTICIPANT-3>"
                }
            },
            "hash": "<hash>",
            "meta": { "proofs": ["..."] }
        }
    ],
    "page": { "total": 3, "index": 0, "limit": 3 }
}

Cada entrada de data es un participante, y el arreglo trae tantos como estén habilitados, así que su longitud cambia a medida que se suman participantes al riel. Los códigos están en data[i].data.custom.entityCode y los nombres en data[i].data.custom.entityName; data[i].data.handle también trae el nombre. Envía el entityCode del participante de destino como el entityCode del destino de la transferencia.

En Participantes activos puedes ver cuáles participantes están habilitados hoy.


Paso 3: Crea la intent de transferencia

El esquema a2a lleva la identidad de ambos extremos de la transferencia: quién envía y quién recibe. Esos campos viajan en source.custom y target.custom.

Definición de campos

NombreTipoDescripción
handle*String (36)Identificador único de la intent y su llave de idempotencia. Los handles son únicos en todo el sistema y no expiran
schema*StringSolo admite:
a2a
action*StringSolo admite:
transfer
amount*IntegerMonto de la transferencia en unidades menores. Mínimo 100
symbol.handle*StringSolo admite:
tcop
source.handle*StringWallet de origen, de donde se debitan los fondos
source.custom.name*String (160)Nombre completo del originador. Solo letras, sin caracteres especiales como ñ o tildes
source.custom.idType*StringTipo de documento del originador. Acepta:
CC
CE
PA
TI
NUIP
NIT
OTR
PPT
PEP
source.custom.idValue*String (34)Número de documento del originador. Solo caracteres alfanuméricos

Cuando idType es NIT, sin dígito de verificación y sin guion (máximo 9 caracteres)
target.handle*String (80)Cuenta de destino, por ejemplo:
svgs:107311223300009@transfiya
target.custom.idType*StringTipo de documento del beneficiario. Acepta:
CC
CE
PA
TI
NUIP
NIT
OTR
PPT
PEP
target.custom.idValue*String (34)Número de documento del beneficiario. Solo caracteres alfanuméricos

Cuando idType es NIT, sin dígito de verificación y sin guion (máximo 9 caracteres)
target.custom.name*String (160)Nombre completo del beneficiario. Solo letras, sin caracteres especiales como ñ o tildes
target.custom.entityCode*String (34)Código de entidad del participante de destino, tomado de la lista de participantes del paso 2
custom.descriptionString (80)Descripción de la transferencia

amount va en unidades menores: multiplica el valor en pesos por el factor (100) del símbolo tcop. COP 1.000 se envía como 100000, y el 12500000 del ejemplo de abajo equivale a COP 125.000. El valor mínimo aceptado es 100, que equivale a COP 1.

El límite es de COP 250.000.000 por transferencia, y estas transferencias se liquidan en tiempo real. El riel funciona 24/7: no hay horarios ni cortes.

Ejemplo de solicitud

Genera el hash, firma y envía al endpoint Create Intent. Consulta Hashing y firma de solicitudes para más detalle.

POST https://<ledger-url>/api/v2/intents
Accept: application/json, text/plain, */*
Content-Type: application/json
x-ledger: <ledger-handle>
{
    "data": {
        "handle": "a2a-order-000123",
        "schema": "a2a",
        "claims": [
            {
                "action": "transfer",
                "source": {
                    "handle": "<source-wallet-handle>",
                    "custom": {
                        "name": "Negocio Ejemplo SAS",
                        "idType": "NIT",
                        "idValue": "900123456"
                    }
                },
                "target": {
                    "handle": "svgs:107311223300009@transfiya",
                    "custom": {
                        "name": "Juan Ejemplo",
                        "idType": "CC",
                        "idValue": "1020304050",
                        "entityCode": "<destination-participant-entity-code>"
                    }
                },
                "symbol": { "handle": "tcop" },
                "amount": 12500000
            }
        ],
        "access": [
            { "action": "any", "signer": { "public": "<public-key>" } }
        ],
        "custom": {
            "description": "Pago a proveedor 000123"
        }
    },
    "hash": "<sha256-hash-of-data>",
    "meta": {
        "proofs": [
            {
                "method": "ed25519-v2",
                "public": "<public-key>",
                "digest": "<digest-of-proof>",
                "result": "<signature-of-digest>",
                "custom": { "moment": "<iso-8601-timestamp>" }
            }
        ]
    }
}

Paso 4: Escucha la finalización

Registra un Effect en la señal intent-updated. Filtrar por el esquema mantiene el handler acotado a las transferencias cuenta a cuenta, y emparejar ambos estados finales hace que también te enteres de un rechazo, donde los fondos regresan al origen.

Genera el hash, firma y envía al endpoint Create Effect.

POST https://<ledger-url>/api/v2/effects
Accept: application/json, text/plain, */*
Content-Type: application/json
x-ledger: <ledger-handle>
{
    "data": {
        "handle": "a2a-intent-completion@<domain>",
        "signal": "intent-updated",
        "filter": {
            "intent.data.schema": "a2a",
            "intent.meta.status": {
                "$in": ["completed", "rejected"]
            }
        },
        "action": {
            "schema": "webhook",
            "endpoint": "<webhook-endpoint>"
        },
        "access": [
            {
                "action": "any",
                "signer": { "public": "<public-key>" }
            }
        ]
    },
    "hash": "<sha256-hash-of-data>",
    "meta": {
        "proofs": [
            {
                "custom": { "moment": "<iso-8601-timestamp>" },
                "method": "ed25519-raw",
                "public": "<public-key>",
                "result": "<hash-signature>"
            }
        ]
    }
}

El handle del effect debe incluir tu dominio, como en a2a-intent-completion@<domain>. Sin el sufijo @<domain> el effect no se puede registrar contra tu wallet.

Para los payloads de entrega y cómo leer un rechazo, consulta Cómo manejar los webhooks de ACH en línea.

Una intent a2a pasa por los estados estándar, created → pending → prepared → committed → completed, sin ningún estado propio del riel. Consulta Ciclo de vida de una intent.


Errores de rechazo

Cuando una transferencia se rechaza, el proof de failed trae un reason y el mensaje de rechazo que define ACH, en español. La misma tabla está en Errores de cuenta a cuenta (A2A).

Razón del errorMensaje de error
bridge.account-inactiveCuenta inactivaNuevo
Cuenta embargadaActualizado
Cuenta canceladaActualizado
Cuenta no asignadaNuevo
Cuenta saldadaActualizado
bridge.account-limit-exceededTopes transaccionales excedidosNuevo
bridge.core-unreachableBanco fuera de servicio por mantenimiento o indisponibilidadNuevo
bridge.entry-duplicatedYa existe una transaccion registrada con el mismo idNuevo
bridge.core-invalid-accessError de acceso invalido en bancoNuevo
bridge.transfer-information-invalidLa informacion de la transferencia no es validaNuevo
bridge.fraud-detectedTransaccion rechazada por prevencion fraude en BancoNuevo
bridge.entry-timeoutTimeoutNuevo
bridge.unexpected-core-errorError interno en el bancoNuevo
Error no identificado en el bancoNuevo
bridge.unexpected-errorError inesperado durante el procesamiento de transaccionNuevo
bridge.account-not-foundIdentificacion no coincide con la cuentaActualizado
Cuenta no existeNuevo
Numero de identificación erradoNuevo
bridge.entry-rejectedCuenta o Deposito Electronico No Habilitado para recibir transaccionesNuevo
bridge.account-insufficient-balanceFondos insuficientesNuevo

Limitaciones y problemas conocidos

  • BBVA (1013) acepta números de cuenta destino de 10, 16 o 20 dígitos. Las transferencias a cuentas BBVA de 9 dígitos siempre se rechazan.

Historial de cambios
1.5.0
  • Agregado• Sección de limitaciones y problemas conocidos, con BBVA aceptando solo números de cuenta de 10, 16 o 21 dígitos
1.4.1
  • Cambiado• `Identificacion no coincide con la cuenta` ahora corresponde a `bridge.account-not-found`, y `Cuenta embargada`, `Cuenta cancelada` y `Cuenta saldada` a `bridge.account-inactive`, por requerimiento del regulador
1.4.0
  • Agregado• Tabla de errores de rechazo de ACH con su razón de error
1.3.2
  • Agregado• Nota de solo letras en `source.custom.name` y `target.custom.name`, sin caracteres especiales como `ñ` o tildes
1.3.1
  • Agregado• Solo caracteres alfanuméricos en las descripciones de `source.custom.idValue` y `target.custom.idValue`, y `NIT` sin dígito de verificación ni guion
1.3.0
  • Cambiado• Definición de campos actualizada con los campos obligatorios y opcionales
1.2.0
  • Eliminado• `custom.externalTxId` de la definición de campos y del ejemplo de solicitud
  • Cambiado• Descripciones de la definición de campos actualizadas
1.1.1
  • Eliminado• Enlace a Consideraciones y restricciones
1.1.0
  • Cambiado• `bankBicfi` ahora es `entityCode`
  • Cambiado• El código del participante se nombra código de entidad, no BICFI
  • Cambiado• `financialInstitutionName` ahora es `entityName` en la lista de participantes
1.0.0
  • Agregado• Versión inicial