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@transfiyaEl tipo de cuenta va en minúsculas y debe coincidir con el tipo real de la cuenta en el banco:
| Código | Tipo de cuenta |
|---|---|
svgs | Cuenta de ahorros |
cacc | Cuenta corriente |
dbmo | Depósito de bajo monto |
dord | Depósito ordinario |
dbmi | Depó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 con el código 309. 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=a2aAuthorization: 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
| Nombre | Tipo | Descripció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* | String | Solo admite: a2a |
| action* | String | Solo admite: transfer |
| amount* | Integer | Monto de la transferencia en unidades menores. Mínimo 100 |
| symbol.handle* | String | Solo admite: tcop |
| source.handle* | String | Wallet 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* | String | Tipo 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* | String | Tipo 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.description | String (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/intentsAccept: 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/effectsAccept: 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, created → pending → prepared → committed → completed, sin ningún estado propio del riel. Consulta Ciclo de vida de una intent.
Historial de cambios
- Agregado• Nota de solo letras en `source.custom.name` y `target.custom.name`, sin caracteres especiales como `ñ` o tildes
- 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
- Cambiado• Definición de campos actualizada con los campos obligatorios y opcionales
- Eliminado• `custom.externalTxId` de la definición de campos y del ejemplo de solicitud
- Cambiado• Descripciones de la definición de campos actualizadas
- Eliminado• Enlace a Consideraciones y restricciones
- 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
- Agregado• Versión inicial