Cómo manejar los webhooks de ACH en línea
Recibe un webhook cuando una transferencia llega a su estado final, completed o rejected, y enrútalo al handler correcto.
El riel necesita un único Effect en la señal intent-updated. No hay notificaciones intermedias que configurar: nada en este riel requiere una acción tuya a mitad de la transferencia.
Prerrequisitos
Antes de empezar:
- Tu endpoint de webhook es HTTPS y es accesible públicamente.
- Leíste los Conceptos clave.
- Puedes configurar Effects en tu wallet.
Responde siempre 200 OK a las entregas de webhook. Kamin reintenta
cualquier respuesta distinta de 200 con backoff exponencial hasta 20 veces,
así que tu handler debe ser idempotente sobre (intentHandle, status).
Configuración del Effect
El effect se registra en Kamin Ledger con el mismo flujo de Cómo registrar y escuchar el effect de estado de una intent. Esa guía cubre autenticación, hashing y firma. Lo que sigue es la parte propia del riel: el signal, el filter y el payload que trae cada entrega.
Incluye tu dominio en el handle del effect (por ejemplo
ach-intent-completion@<domain>). Sin el sufijo @<domain> el effect no se
puede registrar contra tu wallet.
Crea el Effect
Genera el hash, firma y envía al endpoint Create Effect. Consulta Autenticación para los detalles de hashing y firma.
El ejemplo de abajo filtra ambos esquemas, pero nada te obliga a usar los dos
flujos. Filtra por lo que realmente integres: a2a si solo envías
transferencias cuenta a cuenta, transfer si solo envías dispersiones B2P, o
ambos si usas los dos.
POST https://<ledger-url>/api/v2/effectsAccept: application/json, text/plain, */*
Content-Type: application/json
x-ledger: <ledger-handle>{
"data": {
"handle": "ach-intent-completion@<domain>",
"signal": "intent-updated",
"filter": {
"intent.data.schema": {
"$in": ["a2a", "transfer"]
},
"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>"
}
]
}
}Respuesta:
{
"hash": "<sha256-hash-of-data>",
"data": {
"handle": "ach-intent-completion@<domain>",
"signal": "intent-updated",
"filter": {
"intent.data.schema": { "$in": ["a2a", "transfer"] },
"intent.meta.status": { "$in": ["completed", "rejected"] }
},
"action": {
"schema": "webhook",
"endpoint": "<webhook-endpoint>"
},
"access": [
{ "action": "any", "signer": { "public": "<public-key>" } }
]
},
"luid": "<effect-luid>",
"meta": {
"proofs": [
{
"custom": {
"moment": "<iso-8601-timestamp>",
"status": "created"
},
"method": "ed25519-raw",
"public": "<public-key>",
"result": "<hash-signature>"
},
{
"custom": {
"luid": "<effect-luid>",
"moment": "<iso-8601-timestamp>",
"status": "created"
},
"method": "ed25519-v2",
"public": "<ledger-public-key>",
"result": "<ledger-signature>"
}
],
"status": "created",
"moment": "<iso-8601-timestamp>",
"owners": ["<public-key>"]
}
}Por qué estos filtros:
intent.data.schema: { "$in": ["a2a", "transfer"] }— Limita la entrega a las intents de este riel.a2aes una transferencia cuenta a cuenta ytransferes una dispersión B2P. Quita el que no uses, o sepáralos en dos effects para enrutar cada flujo a su propio endpoint.intent.meta.status: { "$in": ["completed", "rejected"] }— Restringe a los estados finales. Las transiciones anteriores del ciclo de vida de una intent no requieren ninguna acción tuya.
Ejemplos de entrega
Cada transición final dispara una entrega a tu endpoint de webhook. data.intent es el objeto completo de la intent con todo su historial de proofs, y los proofs están en data.intent.meta.proofs.
Las listas de proofs de abajo son una estructura base, no la forma exacta.
Una entrega real trae más proofs, y una transferencia completada normalmente
tiene al menos dos proofs prepared y dos committed, porque la intent es
preparada y confirmada por cada participante. Empareja los proofs por su
custom.status, nunca por posición ni por cantidad.
Maneja cada estado
completed — Liquidada
El dinero se movió. Confírmalo con intent.meta.status === "completed" y cierra la orden de tu lado.
rejected — Fondos devueltos
La transferencia falló y los fondos regresaron al origen, así que el saldo queda restituido sin que hagas nada. Confírmalo con intent.meta.status === "rejected".
El proof de rejected solo marca la transición final. Para la causa, recorre data.intent.meta.proofs y busca el proof anterior cuyo custom.status sea failed. Trae dos campos:
reasonnombra el tipo de falla y cambia según la causa:bridge.account-not-found,bridge.account-insufficient-balance,bridge.account-limit-exceeded,bridge.core-unreachable,bridge.entry-rejectedy otros, conbridge.unexpected-errorcomo comodín. Ramifica sobre este.detailtrae el código y el mensaje de rechazo propios del riel, por ejemplo el código309para una cuenta que no existe. Registra este.
Ambos están listados en Errores del bridge en la referencia de errores. Esa es la sección de este riel: las secciones de Bre-B, DICE, MOL y PSE de esa página son de otros rieles y nunca aplican aquí. Empareja por el código y no por el texto del mensaje, porque el riel lo envía en español o en inglés según el participante.
Siguiente paso
Con el effect listo, construye la transferencia: Cómo enviar una transferencia cuenta a cuenta (A2A) o Cómo crear una dispersión de negocio a persona (B2P).
Historial de cambios
- Agregado• Versión inicial