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 BICFI 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 bankBicfi 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 con el código 309. Ante cualquier rechazo los fondos regresan al origen.


Paso 2: Obtén el BICFI 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": {
                    "bankBicfi": "<participant-1-bicfi>",
                    "financialInstitutionName": "<PARTICIPANT-1>"
                }
            },
            "hash": "<hash>",
            "meta": { "proofs": ["..."] }
        },
        {
            "data": {
                "handle": "<participant-2>",
                "target": "a2a",
                "custom": {
                    "bankBicfi": "<participant-2-bicfi>",
                    "financialInstitutionName": "<PARTICIPANT-2>"
                }
            },
            "hash": "<hash>",
            "meta": { "proofs": ["..."] }
        },
        {
            "data": {
                "handle": "<participant-3>",
                "target": "a2a",
                "custom": {
                    "bankBicfi": "<participant-3-bicfi>",
                    "financialInstitutionName": "<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.bankBicfi y los nombres en data[i].data.custom.financialInstitutionName; data[i].data.handle también trae el nombre. Envía el bankBicfi del participante de destino como el bankBicfi 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, sin ventana de expiración
schema*StringSolo admite:
a2a
claims[].action*StringSolo admite:
transfer
claims[].amount*IntegerMonto de la transferencia en unidades menores. Mínimo 100
claims[].symbol.handle*StringSolo admite:
tcop
claims[].source.handle*StringTu wallet, de donde se debitan los fondos
claims[].source.custom.name*String (160)Nombre completo del originador
claims[].source.custom.idTypeStringTipo de documento del originador. Acepta:
CC
CE
PA
TI
NUIP
NIT
OTR
PPT
PEP
claims[].source.custom.idValueString (34)Número de documento del originador
claims[].source.custom.bankBicfiString (34)BICFI del participante de origen. No se envía en una transferencia que tú originas; aparece en el origen de una transferencia entrante
claims[].target.handle*String (80)Cuenta de destino, por ejemplo:
svgs:107311223300009@transfiya
claims[].target.custom.idType*StringTipo de documento del beneficiario. Acepta:
CC
CE
PA
TI
NUIP
NIT
OTR
PPT
PEP
claims[].target.custom.idValue*String (34)Número de documento del beneficiario
claims[].target.custom.nameString (160)Nombre completo del beneficiario
claims[].target.custom.bankBicfiString (34)BICFI del participante de destino, tomado de la lista de participantes del paso 2
custom.descriptionString (80)Descripción de la transferencia
custom.externalTxIdStringTu propio identificador de la transacción (CUS)

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",
                        "bankBicfi": "<destination-participant-bicfi>"
                    }
                },
                "symbol": { "handle": "tcop" },
                "amount": 12500000
            }
        ],
        "access": [
            { "action": "any", "signer": { "public": "<public-key>" } }
        ],
        "custom": {
            "description": "Pago a proveedor 000123",
            "externalTxId": "TX-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.

Cuando una transferencia se rechaza, el proof de failed trae un reason y el código propio del riel en detail. Ambos están listados en Errores del bridge, la sección de la referencia de errores que aplica a este riel.

Una intent a2a pasa por los estados estándar, createdpendingpreparedcommittedcompleted, sin ningún estado propio del riel. Consulta Ciclo de vida de una intent.

Revisa las Consideraciones y restricciones para conocer los formatos de número de cuenta de bancos específicos antes de enviar en producción.


Historial de cambios
1.0.0
  • Agregado Versión inicial