Webhooks

Webhooks

Argentive Card te POSTea eventos firmados a tus endpoints registrados. Mismo contrato y formato que Stripe / AgentCard, pensado para que sea trivial de integrar.

Overview

  • Endpoint: HTTPS público en tu lado. Registralo con POST /api/v1/webhook_endpoints.
  • Firma: header Argentive-Signature con HMAC-SHA256 sobre el body raw + timestamp.
  • Tolerancia replay: 5 minutos. Rechazá eventos con timestamp fuera de esa ventana.
  • Reintentos: 5 intentos en ~1h36min con backoff exponencial. Si tu endpoint responde 5xx, reintentamos.
  • Idempotencia: dedupe por id del evento.

Envelope de un evento

evento
{
  "id":       "evt_01HW3FN9XK4MNQ...",
  "type":     "transaction.cleared",
  "created":  1719234801,
  "livemode": true,
  "data": {
    "object": {
      "id":          "tx_01HW3FN9XK...",
      "cardId":      "card_01HW3FNE2R...",
      "cardholderId":"ch_01HW3FN4Y8...",
      "amountCents": 2490,
      "currency":    "USD",
      "merchant": {
        "name":      "AMAZON SVCS",
        "category":  "5942"
      },
      "status":      "settled",
      "authorizedAt":"2026-06-24T11:49:00Z",
      "clearedAt":   "2026-06-24T11:53:18Z"
    }
  }
}

Eventos disponibles

cardholder.created
Se creó un nuevo cardholder. kycStatus puede ser pending todavía.
cardholder.updated
Cambió el estado del KYC (approved/rejected) o se editó algún campo.
cardholder_onboarding_session.completed
El user terminó el onboarding hosted. El cardholderId está en data.
card.created
Card emitida con éxito. Hold confirmado en MP/dLocal.
card.updated
Cambió el balance, el spend_limit o cualquier campo de la card.
card.closed
Card cerrada — explícitamente por DELETE o automáticamente tras single-use.
transaction.authorized
El issuer aprobó una autorización contra la card. Pre-captura.
transaction.declined
Una autorización fue rechazada (sin saldo, card cerrada, MCC bloqueado).
transaction.cleared
La autorización se capturó. Se cobró el monto real al payment method.
transaction.voided
Una autorización fue anulada antes de capturarse. El hold se libera.
balance.low
El balance del cardholder bajó del threshold configurado en el dashboard.
Wildcards
Cuando registrás un endpoint, podés usar wildcards en enabledEvents: card.*, transaction.*, cardholder.*, o * para todos.

Firma HMAC SHA-256

Cada request incluye un header Argentive-Signature con dos campos separados por coma: t=<epoch> (timestamp Unix en segundos) y v1=<hex> (firma).

header
Argentive-Signature: t=1719234801,v1=8d3c5a1e9f...

Algoritmo de verificación

algorithm
payload   = t + "." + raw_body            (el body como llegó, sin parsear)
expected  = HMAC_SHA256(secret, payload)  (hex lowercase)
verify    = compare_constant_time(v1, expected)
fresh     = (now_epoch - t) <= 300        (5 min de tolerancia)
ok        = verify && fresh
Atención
Usá el body raw, no el parseado. Cualquier reformateo del JSON (espacios, orden de keys) invalida la firma. En Express usá express.raw({ type: 'application/json' }).

Verificación en Node.js

verify.ts
import crypto from 'crypto'

const SECRET = process.env.ARGENTIVE_WEBHOOK_SECRET!  // whsec_...
const TOLERANCE_SECONDS = 300

export function verifyWebhook(rawBody: Buffer, signatureHeader: string): boolean {
  const match = signatureHeader.match(/^t=(\d+),v1=([0-9a-f]+)$/)
  if (!match) return false
  const [, t, v1] = match

  // 1. Anti-replay
  const age = Math.floor(Date.now() / 1000) - Number(t)
  if (Math.abs(age) > TOLERANCE_SECONDS) return false

  // 2. Reconstruir y comparar
  const payload = `${t}.${rawBody.toString('utf8')}`
  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(payload)
    .digest('hex')

  return crypto.timingSafeEqual(
    Buffer.from(v1, 'hex'),
    Buffer.from(expected, 'hex'),
  )
}

Verificación en Python

verify.py
import os, hmac, hashlib, time, re

SECRET = os.environ['ARGENTIVE_WEBHOOK_SECRET']
TOLERANCE_SECONDS = 300

def verify_webhook(raw_body: bytes, signature_header: str) -> bool:
    m = re.match(r'^t=(\d+),v1=([0-9a-f]+)$', signature_header)
    if not m:
        return False
    t, v1 = m.groups()

    # 1. Anti-replay
    if abs(int(time.time()) - int(t)) > TOLERANCE_SECONDS:
        return False

    # 2. HMAC
    payload = f'{t}.{raw_body.decode("utf-8")}'
    expected = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()

    return hmac.compare_digest(v1, expected)

Best practices del handler

1. Respondé 2xx rápido

Argentive considera success cualquier respuesta 2xx. Si tardás más de 10 segundos, marcamos el intento como fallido y reintentamos. Mejor: respondé 200 inmediatamente y procesá async en background.

handler.ts
// Next.js route handler
export async function POST(req: Request) {
  const rawBody = Buffer.from(await req.arrayBuffer())
  const signature = req.headers.get('Argentive-Signature') ?? ''

  if (!verifyWebhook(rawBody, signature)) {
    return new Response('invalid signature', { status: 401 })
  }

  const event = JSON.parse(rawBody.toString('utf8'))

  // Procesar async — NO bloquear la respuesta
  void processEvent(event).catch(console.error)

  return new Response('ok', { status: 200 })
}

2. Idempotencia por evt_id

Por diseño Argentive puede entregar el mismo evento más de una vez (en reintentos tras un timeout, por ejemplo). Dedupe usando el campo id:

processEvent.ts
async function processEvent(event: ArgentiveEvent) {
  const already = await db.processedWebhooks.findUnique({ where: { evtId: event.id } })
  if (already) return  // ya lo procesamos

  await db.processedWebhooks.create({ data: { evtId: event.id, type: event.type } })

  switch (event.type) {
    case 'transaction.cleared':  return handleClear(event.data.object)
    case 'card.closed':           return handleCardClosed(event.data.object)
    // ...
  }
}

3. No actúes sobre transaction.authorized

Atención
transaction.authorized es un estado intermedio — puede ser seguido porcleared O voided. Si actualizás tu UI o emitís comprobante en authorized, vas a tener inconsistencias. Confiá en cleared para todo lo que afecte usuarios o contabilidad.

4. Manejá failure count

Si tu endpoint falla 50 veces consecutivas, Argentive lo marca como status: degraded y reduce la frecuencia de reintentos. Después de 100 fallos consecutivos pasa a disabled y dejamos de enviarte eventos. Tenés que reactivarlo desde el dashboard o vía API.

Testing

Para desarrollo local, usá el CLI con argentive-admin listen — tunelea webhooks productivos o test a tu localhost. Ver CLI admin.

terminal
argentive-admin listen --forward-to http://localhost:3000/api/webhooks

# Trigger manual para probar handlers
argentive-admin trigger transaction.cleared --card card_01HW3...
argentive-admin trigger card.closed