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/effects
Accept: 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. a2a es una transferencia cuenta a cuenta y transfer es 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:

  • reason nombra 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-rejected y otros, con bridge.unexpected-error como comodín. Ramifica sobre este.
  • detail trae el código y el mensaje de rechazo propios del riel, por ejemplo el código 309 para 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
1.0.0
  • Agregado Versión inicial