Desarrolladores/Integración

Webhooks

Un webhook es un mensaje que CardV envía a tu servidor cuando un pedido termina. Así no tienes que consultar el pedido una y otra vez. Nunca incluye códigos: después de recibir un webhook, lee el pedido con GET/api/v1/orders/{order_id}.

Las URLs de webhook se configuran en el Portal (Owner, Integrations → Webhooks), por separado para Live y para Sandbox. No hay API para esto.

Ver también: Catálogo y pedidos · Seguridad

#Eventos

EventoSe envía cuando el pedido pasa a
order.succeededsucceeded
order.partially_succeededpartially_succeeded
order.failedfailed (todavía no es un reembolso)
order.refundedrefunded (el dinero volvió a tu billetera)
  • Cada evento se envía como máximo una vez por pedido y por URL de webhook (más los reintentos).
  • No hay eventos para accepted ni processing, ni cuando agregas fondos.

#Contenido del mensaje

CardV envía un POST a tu URL HTTPS:

HTTP
POST /cardv/webhook HTTP/1.1
Content-Type: application/json
User-Agent: CardV-B2B-Webhook/1.0
X-CardV-Event: order.succeeded
X-CardV-Delivery: 5521
X-CardV-Timestamp: 1790000100
X-CardV-Signature: t=1790000100,v1=2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27

El cuerpo, con formato para que se lea mejor (CardV lo envía en una sola línea):

JSON
{
  "event": "order.succeeded",
  "order": {
    "id": "O-00000001",
    "order_id": "O-00000001",
    "external_order_id": "TEST-0001",
    "status": "succeeded",
    "currency": "USD",
    "total_amount": "9.2500",
    "items": [
      {
        "sku_id": "S000001",
        "product_name": "Example Card",
        "quantity": 1,
        "delivery_count": 1
      }
    ]
  }
}
  • order.order_id es el ID de pedido de CardV. order.id tiene el mismo valor.
  • order.external_order_id es tu número de pedido.
  • order.status es el estado en el momento del evento. Puede estar desactualizado.
  • X-CardV-Delivery es el ID de este mensaje. El Portal lo muestra en el historial de envíos.

#Verificación de la firma

Cada webhook va firmado con el secreto de firma de tu webhook (whsec_...). El Portal muestra el secreto una sola vez: cuando creas el webhook o cuando restableces el secreto.

Así funciona la firma:

Text
X-CardV-Signature: t=<segundos Unix>,v1=<hexadecimal en minúsculas>
v1 = HMAC-SHA256(clave = secreto de firma, mensaje = "<t>" + "." + bytes del cuerpo tal como llegan)

Pasos:

  1. Lee los bytes del cuerpo tal como llegan, antes de interpretar el JSON.
  2. Toma t y v1 del encabezado.
  3. Rechaza el mensaje si t difiere más de 300 segundos de tu reloj.
  4. Calcula el v1 esperado y compáralo con una comparación de tiempo constante.
  5. Solo entonces interpreta el JSON.

Solo se firman t y el cuerpo. Toma el tipo de evento del campo event del cuerpo, no del encabezado X-CardV-Event.

Vector de prueba (secreto falso):

Text
secret   whsec_TEST_ONLY_not_a_real_secret_000000000000
t        1790000100
v1       2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27

El cuerpo de este vector es exactamente esta línea (266 bytes, sin salto de línea al final):

JSON
{"event":"order.succeeded","order":{"id":"O-00000001","order_id":"O-00000001","external_order_id":"TEST-0001","status":"succeeded","currency":"USD","total_amount":"9.2500","items":[{"sku_id":"S000001","product_name":"Example Card","quantity":1,"delivery_count":1}]}}

Python (Flask)

Python
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["CARDV_WEBHOOK_SECRET"].encode()


def verify(raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    try:
        parts = dict(item.split("=", 1) for item in header.split(","))
        t, v1 = parts["t"].strip(), parts["v1"].strip()
    except (KeyError, ValueError):
        return False
    if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    signed = t.encode() + b"." + raw_body
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), v1.encode())  # constant time


@app.post("/cardv/webhook")
def cardv_webhook():
    raw = request.get_data()  # raw bytes, before JSON parsing
    if not verify(raw, request.headers.get("X-CardV-Signature", "")):
        abort(400)
    event = request.get_json()
    store_event(request.headers["X-CardV-Delivery"], event)  # your code
    return "", 204  # answer fast; do the work later

Node.js (Express)

Node.js
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.CARDV_WEBHOOK_SECRET;

function verify(rawBody, header, toleranceSeconds = 300) {
  const parts = {};
  for (const p of String(header || "").split(",")) {
    const i = p.indexOf("=");
    parts[p.slice(0, i).trim()] = p.slice(i + 1).trim();
  }
  const { t, v1 } = parts;
  if (!t || !v1 || !/^\d+$/.test(t)) return false;
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(t)) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", SECRET)
    .update(Buffer.concat([Buffer.from(`${t}.`, "utf8"), rawBody]))
    .digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(v1, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw keeps the exact bytes. Do not use express.json() on this route.
const rawJson = express.raw({ type: "application/json" });

app.post("/cardv/webhook", rawJson, async (req, res) => {
  if (!verify(req.body, req.get("X-CardV-Signature"))) return res.sendStatus(400);
  const event = JSON.parse(req.body.toString("utf8"));
  await storeEvent(req.get("X-CardV-Delivery"), event); // your code
  res.sendStatus(204);
});

Los dos ejemplos pasan el vector de prueba.

#Reintentos

  • Responde con cualquier código 2xx en menos de 15 segundos, después de guardar el evento. Deja el trabajo lento (leer el pedido, enviar los códigos) para después.
  • Cualquier otra cosa cuenta como fallo: otro código, un tiempo agotado o un error de conexión.
  • CardV no sigue redirecciones. Una respuesta 3xx cuenta como fallo. Registra la URL final exacta.
  • Un mensaje fallido se reintenta, hasta 6 intentos en total:
IntentoCuándo
1Justo después del evento
2Alrededor de 1 minuto después de que falló el intento 1
3Alrededor de 5 minutos después de que falló el intento 2
4Alrededor de 15 minutos después de que falló el intento 3
5Alrededor de 30 minutos después de que falló el intento 4
6Alrededor de 60 minutos después de que falló el intento 5

Los tiempos pueden retrasarse hasta un minuto. Después del sexto fallo, CardV deja de intentar. En el historial de envíos del Portal puedes ver cada mensaje y volver a enviarlo.

#Duplicados

El mismo evento puede llegar más de una vez, y los eventos pueden llegar desordenados.

  • Ignora los eventos que ya procesaste. Identifícalos por order.order_id más event, tomados del cuerpo ya verificado. Un mensaje reenviado desde el Portal recibe un nuevo ID X-CardV-Delivery, así que ese ID por sí solo no basta.
  • El cuerpo es una foto del momento en que ocurrió el evento. Vuelve siempre a leer el pedido y actúa según su estado actual.
  • Algunos webhooks pueden perderse. Ejecuta también una tarea que revise los pedidos no terminados con más de unos minutos de antigüedad.

#Reglas de la URL

  • La URL debe empezar con https:// y apuntar a una dirección pública de internet. Las direcciones privadas y locales se rechazan.
  • Puedes suscribirte a los cuatro eventos o elegir solo algunos.
  • Restablecer el secreto de firma tiene efecto inmediato. El secreto anterior deja de funcionar. Para cambiarlo sin interrumpir el servicio, agrega un segundo webhook con un secreto nuevo, instálalo y luego desactiva el anterior.
  • CardV guarda los primeros 1,000 caracteres de tu respuesta para diagnosticar problemas. No pongas secretos ni datos personales en tu respuesta.

¿Dudas sobre tu integración? Escribe a [email protected] con tu Merchant ID y el ID del pedido o de la solicitud.