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.
Request que recibes
Sección titulada «Request que recibes»POST <tu callbackUrl>Content-Type: application/jsonUser-Agent: ms-payment-c2cX-VP-Event-Id: evt-c2c-4b6e60ad-33fa-4c63-952d-91368108795aX-VP-Timestamp: 2026-11-13T16:01:30.492ZX-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. |
Verificar la firma
Sección titulada «Verificar la firma»Verifica cada webhook antes de procesarlo:
- Lee el body tal como llegó (los bytes crudos), antes de parsear el JSON.
- Calcula HMAC-SHA256 de ese body con tu secreto de webhook, en hexadecimal.
- Compara
sha256=<tu cálculo>con el headerX-VP-Signatureusando una comparación de tiempo constante. Si no coinciden, responde401y descarta el webhook. - Rechaza el webhook si
X-VP-Timestamptiene más de 5 minutos de diferencia con tu hora actual, para evitar que alguien reenvíe un webhook antiguo.
Node.js (Express)
Sección titulada «Node.js (Express)»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);Python (Flask)
Sección titulada «Python (Flask)»import hashlib, hmac, osfrom datetime import datetime, timezonefrom 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 "", 204Cómo responder
Sección titulada «Cómo responder»- Responde con cualquier código
2xx, idealmente200o204, 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(opaymentRequestId+status) para no ejecutar dos veces la misma acción de negocio.
Reintentos
Sección titulada «Reintentos»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.
Probar tus webhooks
Sección titulada «Probar tus 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
callbackUrly 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.