Ir al contenido

Webhooks

Si informas un callbackUrl al crear la operación, Virtualpos envía un webhook a esa URL cada vez que la operación cambia de estado: por ejemplo, cuando el POS la toma (CLAIMED) y cuando termina (APPROVED, REJECTED, etc.).

Para recibir webhooks necesitas el secreto de webhook de tu comercio, que te entrega tu ejecutivo. Sin ese secreto configurado, Virtualpos no envía webhooks, porque todos van firmados.

POST <tu callbackUrl>
Content-Type: application/json
User-Agent: ms-payment-c2c
X-VP-Event-Id: evt-c2c-4b6e60ad-33fa-4c63-952d-91368108795a
X-VP-Timestamp: 2026-11-13T16:01:30.492Z
X-VP-Signature: sha256=5d2c1f...e9a0
Header Descripción
X-VP-Event-Id ID único del evento. Igual a eventId del body.
X-VP-Timestamp Fecha del evento, en UTC. Igual a createdAt del body.
X-VP-Signature sha256= seguido de la firma HMAC-SHA256 del body, en hexadecimal.
{
"event": "PAYMENT_C2C_UPDATED",
"eventId": "evt-c2c-4b6e60ad-33fa-4c63-952d-91368108795a",
"createdAt": "2026-11-13T16:01:30.492Z",
"paymentRequestId": "8f1c2d3e-4b5a-4c6d-9e7f-0a1b2c3d4e5f",
"externalPaymentId": "orden-10234",
"posSerial": "VPOS-1A2B3C4D5E",
"operationType": "SALE",
"status": "APPROVED",
"jobId": "job-5d6e7f80-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
"attemptId": "orden-10234#attempt-1",
"attemptNumber": 1,
"resultPayload": {
"requestId": "orden-10234",
"resultStatus": "approved",
"message": "Venta aprobada",
"authCode": "238518",
"posTransactionId": "1b39fb38-8625-400c-8b5e-0f95886a5a08"
},
"errorPayload": null
}
Campo Descripción
event Siempre PAYMENT_C2C_UPDATED.
status Nuevo estado de la operación. Ver estados.
resultPayload Detalle informado por el POS. Depende de la operación: ver venta, código de barras y serial.
errorPayload Detalle del error cuando la operación falla. null si no hay error.

Verifica cada webhook antes de procesarlo:

  1. Lee el body tal como llegó (los bytes crudos), antes de parsear el JSON.
  2. Calcula HMAC-SHA256 de ese body con tu secreto de webhook, en hexadecimal.
  3. Compara sha256=<tu cálculo> con el header X-VP-Signature usando una comparación de tiempo constante. Si no coinciden, responde 401 y descarta el webhook.
  4. Rechaza el webhook si X-VP-Timestamp tiene más de 5 minutos de diferencia con tu hora actual, para evitar que alguien reenvíe un webhook antiguo.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRETO = process.env.VP_WEBHOOK_SECRET;
app.post('/webhooks/virtualpos', express.raw({ type: 'application/json' }), (req, res) => {
const esperada = 'sha256=' + crypto.createHmac('sha256', SECRETO).update(req.body).digest('hex');
const recibida = req.get('X-VP-Signature') ?? '';
const firmaOk =
recibida.length === esperada.length &&
crypto.timingSafeEqual(Buffer.from(recibida), Buffer.from(esperada));
const antiguedad = Math.abs(Date.now() - Date.parse(req.get('X-VP-Timestamp')));
if (!firmaOk || !(antiguedad <= 5 * 60 * 1000)) return res.sendStatus(401);
const evento = JSON.parse(req.body);
// Procesa el evento de forma idempotente por evento.eventId
res.sendStatus(204);
});
<?php
$secreto = getenv('VP_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$esperada = 'sha256=' . hash_hmac('sha256', $body, $secreto);
$recibida = $_SERVER['HTTP_X_VP_SIGNATURE'] ?? '';
$antiguedad = abs(time() - strtotime($_SERVER['HTTP_X_VP_TIMESTAMP'] ?? ''));
if (!hash_equals($esperada, $recibida) || $antiguedad > 300) {
http_response_code(401);
exit;
}
$evento = json_decode($body, true);
// Procesa el evento de forma idempotente por $evento['eventId']
http_response_code(204);
import hashlib, hmac, os
from datetime import datetime, timezone
from flask import Flask, request
app = Flask(__name__)
SECRETO = os.environ["VP_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/virtualpos")
def webhook_virtualpos():
body = request.get_data()
esperada = "sha256=" + hmac.new(SECRETO, body, hashlib.sha256).hexdigest()
recibida = request.headers.get("X-VP-Signature", "")
ts = datetime.fromisoformat(request.headers["X-VP-Timestamp"].replace("Z", "+00:00"))
antiguedad = abs((datetime.now(timezone.utc) - ts).total_seconds())
if not hmac.compare_digest(esperada, recibida) or antiguedad > 300:
return "", 401
evento = request.get_json()
# Procesa el evento de forma idempotente por evento["eventId"]
return "", 204
  • Responde con cualquier código 2xx, idealmente 200 o 204, apenas verifiques la firma. Si tienes procesos largos, hazlos después de responder.
  • Tienes 5 segundos para responder. Si demoras más, el intento se considera fallido.
  • Tu endpoint debe ser idempotente: puedes recibir el mismo webhook más de una vez. Usa eventId (o paymentRequestId + status) para no ejecutar dos veces la misma acción de negocio.

Si tu endpoint no responde 2xx, Virtualpos reintenta la entrega hasta 5 veces en total: al momento del evento y luego a los 5, 10, 30 y 120 segundos. Los reintentos se detienen con la primera respuesta 2xx.

Un webhook que no pudiste recibir no cambia el estado de la operación. Si se agotan los reintentos, consulta el estado con Consultar una operación y revisa qué pasó con Diagnóstico de webhooks.

Mientras desarrollas, puedes usar un servicio que te entrega una URL pública para inspeccionar los webhooks que recibes:

  • Webhook.site: la opción más simple. Abre el sitio, copia tu URL única, úsala como callbackUrl y verás cada webhook con sus headers y su body.
  • Beeceptor: crea endpoints HTTPS de prueba y revisa los requests recibidos.
  • Pipedream: útil si además quieres ejecutar un flujo automático cuando llegue el webhook.

Combínalo con el simulador de POS para aprobar o rechazar operaciones y ver el webhook de cada caso.