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
| Evento | Se envía cuando el pedido pasa a |
|---|---|
order.succeeded | succeeded |
order.partially_succeeded | partially_succeeded |
order.failed | failed (todavía no es un reembolso) |
order.refunded | refunded (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
acceptedniprocessing, ni cuando agregas fondos.
#Contenido del mensaje
CardV envía un POST a tu URL HTTPS:
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=2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27El cuerpo, con formato para que se lea mejor (CardV lo envía en una sola línea):
{
"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_ides el ID de pedido de CardV.order.idtiene el mismo valor.order.external_order_ides tu número de pedido.order.statuses el estado en el momento del evento. Puede estar desactualizado.X-CardV-Deliveryes 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:
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:
- Lee los bytes del cuerpo tal como llegan, antes de interpretar el JSON.
- Toma
tyv1del encabezado. - Rechaza el mensaje si
tdifiere más de 300 segundos de tu reloj. - Calcula el
v1esperado y compáralo con una comparación de tiempo constante. - 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):
secret whsec_TEST_ONLY_not_a_real_secret_000000000000
t 1790000100
v1 2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27El cuerpo de este vector es exactamente esta línea (266 bytes, sin salto de línea al final):
{"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)
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 laterNode.js (Express)
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:
| Intento | Cuándo |
|---|---|
| 1 | Justo después del evento |
| 2 | Alrededor de 1 minuto después de que falló el intento 1 |
| 3 | Alrededor de 5 minutos después de que falló el intento 2 |
| 4 | Alrededor de 15 minutos después de que falló el intento 3 |
| 5 | Alrededor de 30 minutos después de que falló el intento 4 |
| 6 | Alrededor 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_idmásevent, tomados del cuerpo ya verificado. Un mensaje reenviado desde el Portal recibe un nuevo IDX-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.