Autenticación y firma
En pocas palabras
Sección titulada «En pocas palabras»En la configuración estándar, los webhooks de IDPOS no usan usuario, contraseña ni token en la petición. IDPOS firma cada envío con el secreto de su suscripción y usted vuelve a calcular esa firma con el mismo secreto. Si ambas coinciden, el envío es auténtico y no fue alterado.
Una suscripción puede incluir cabeceras adicionales acordadas con IDBI. La firma sigue siendo la forma de comprobar que el envío viene de IDPOS.
Qué llega en cada envío
Sección titulada «Qué llega en cada envío»| Cabecera | Descripción |
|---|---|
Content-Type | application/json |
User-Agent | ID-Webhook/1.0 |
X-Webhook-Id | Identificador único del evento. Es igual al event_id del cuerpo y no cambia entre reintentos. No forma parte del mensaje firmado. |
X-Webhook-Timestamp | Marca de tiempo Unix, en segundos. Cambia en cada intento de envío. |
X-Webhook-Signature | Firma HMAC-SHA256 en hexadecimal. Cambia en cada intento de envío. |
Verifique la firma en 6 pasos
Sección titulada «Verifique la firma en 6 pasos»-
Tenga a mano el secreto.
Es la cadena que IDBI le entregó una sola vez al registrar su suscripción. Cada suscripción tiene el suyo. Guárdelo fuera del código fuente, por ejemplo en la variable de entorno
IDPOS_WEBHOOK_SECRET. -
Lea las cabeceras y el cuerpo crudo.
Tome
X-Webhook-TimestampyX-Webhook-Signature, y los bytes del cuerpo tal como llegaron, sin interpretarlos. Si falta alguna cabecera, rechace el envío. -
Arme el texto a firmar:
{timestamp}.{cuerpo}.Es el valor de
X-Webhook-Timestamp, un punto (.) y el cuerpo. Con el vector de prueba, el texto es:1791815400.{"event_id":"01J9ZK3Q7V4N8X2M5T6R1B0C9D","event_type":"order.accepted","timestamp":"2026-10-12T14:30:00Z","api_version":"v1","data":{"id":"9b6f0c3e-1a2d-4c55-8f01-3d2a7e6b9a10","reference":"OV-001234"}} -
Calcule el HMAC-SHA256.
Use el secreto como clave y el texto del paso anterior como mensaje. El resultado debe ir en hexadecimal y en minúsculas: es la firma esperada.
-
Compare en tiempo constante.
Compare la firma esperada con
X-Webhook-Signaturecon una función de comparación en tiempo constante. No use==ni===: la comparación normal puede filtrar información por el tiempo que tarda. -
Compruebe la marca de tiempo.
Rechace el envío si
X-Webhook-Timestampdifiere en más de 300 segundos (5 minutos) de la hora actual de su servidor. Esto limita la ventana en la que un envío capturado puede reenviarse, pero no la elimina: dentro de esos 300 segundos el mismo envío sigue pareciendo válido. Por eso debe descartar duplicados con elevent_iddel cuerpo ya verificado, no con la cabeceraX-Webhook-Id, que no está firmada. Mantenga el reloj de su servidor sincronizado.
Cómo se calcula la firma
Sección titulada «Cómo se calcula la firma»X-Webhook-Signature = HMAC-SHA256(secreto, "{X-Webhook-Timestamp}.{cuerpo}") en hexadecimal| Elemento | Valor |
|---|---|
| Algoritmo | HMAC con SHA-256 |
| Clave | El secreto de la suscripción, entregado por IDBI |
| Mensaje | El valor de X-Webhook-Timestamp, un punto (.) y el cuerpo de la petición tal como llegó |
| Salida | Hexadecimal en minúsculas |
Ejemplos de código
Sección titulada «Ejemplos de código»Cada ejemplo tiene dos partes: la función que verifica la firma y el endpoint que la usa. Ambos operan sobre el cuerpo crudo, comparan en tiempo constante y aplican la tolerancia de 300 segundos. El secreto se lee de la variable de entorno IDPOS_WEBHOOK_SECRET. En los endpoints, processLater (process_later en Python) representa la cola o el proceso en segundo plano de su sistema. Reciben el event_id del cuerpo ya verificado: úselo para descartar duplicados antes de procesar.
Requiere Node.js 18 o superior y Express 4 o superior. La ruta usa express.raw para recibir el cuerpo como Buffer, sin analizar el JSON.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function isValidSignature({ rawBody, timestamp, signature, secret, now = Date.now() / 1000 }) { if (!Buffer.isBuffer(rawBody) || !/^\d{1,15}$/.test(timestamp ?? '')) { return false; }
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) { return false; }
const expected = createHmac('sha256', secret) .update(`${timestamp}.`) .update(rawBody) .digest('hex');
const expectedBytes = Buffer.from(expected); const receivedBytes = Buffer.from(signature ?? '');
return ( expectedBytes.length === receivedBytes.length && timingSafeEqual(expectedBytes, receivedBytes) );}import express from 'express';import { isValidSignature } from './verify-signature.mjs';
const app = express();
app.post( '/webhooks/idpos', express.raw({ type: 'application/json', limit: '5mb' }), (req, res) => { const valid = isValidSignature({ rawBody: req.body, timestamp: req.get('X-Webhook-Timestamp'), signature: req.get('X-Webhook-Signature'), secret: process.env.IDPOS_WEBHOOK_SECRET, });
if (!valid) { return res.sendStatus(401); }
const event = JSON.parse(req.body.toString('utf8'));
processLater(event.event_id, event);
res.sendStatus(200); },);
app.listen(3000);Requiere PHP 8.0 o superior. file_get_contents('php://input') entrega el cuerpo crudo.
<?php
const TOLERANCE_SECONDS = 300;
function isValidSignature( string $rawBody, string $timestamp, string $signature, string $secret, ?int $now = null): bool { if (!ctype_digit($timestamp)) { return false; }
if (abs(($now ?? time()) - (int) $timestamp) > TOLERANCE_SECONDS) { return false; }
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);}<?php
require __DIR__ . '/verify-signature.php';
$secret = getenv('IDPOS_WEBHOOK_SECRET');
if ($secret === false || $secret === '') { http_response_code(500); exit;}
$rawBody = file_get_contents('php://input');$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!isValidSignature($rawBody, $timestamp, $signature, $secret)) { http_response_code(401); exit;}
$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
processLater($event['event_id'], $event);
http_response_code(200);Requiere Python 3.10 o superior y Flask 2 o superior. request.get_data() entrega el cuerpo crudo como bytes.
import hashlibimport hmacimport time
TOLERANCE_SECONDS = 300
def is_valid_signature( raw_body: bytes, timestamp: str, signature: str, secret: str, now: float | None = None,) -> bool: if not (timestamp.isascii() and timestamp.isdigit()): return False
current_time = time.time() if now is None else now if abs(current_time - int(timestamp)) > TOLERANCE_SECONDS: return False
signed_payload = timestamp.encode() + b"." + raw_body expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), signature.encode())import jsonimport os
from flask import Flask, abort, request
from verify_signature import is_valid_signature
app = Flask(__name__)
@app.post("/webhooks/idpos")def receive_idpos_webhook(): raw_body = request.get_data()
if not is_valid_signature( raw_body=raw_body, timestamp=request.headers.get("X-Webhook-Timestamp", ""), signature=request.headers.get("X-Webhook-Signature", ""), secret=os.environ["IDPOS_WEBHOOK_SECRET"], ): abort(401)
event = json.loads(raw_body)
process_later(event["event_id"], event)
return "", 200Vector de prueba
Sección titulada «Vector de prueba»Use estos valores para comprobar su implementación antes de recibir envíos reales.
| Elemento | Valor |
|---|---|
| Secreto | secreto-de-prueba |
X-Webhook-Timestamp | 1791815400 |
X-Webhook-Signature esperada | b9452f6af0e368501a8b3fc3c059e5ac9723a6753eb0e2006280abc524ccf7b8 |
Cuerpo, en una sola línea y sin salto de línea final (202 bytes):
{"event_id":"01J9ZK3Q7V4N8X2M5T6R1B0C9D","event_type":"order.accepted","timestamp":"2026-10-12T14:30:00Z","api_version":"v1","data":{"id":"9b6f0c3e-1a2d-4c55-8f01-3d2a7e6b9a10","reference":"OV-001234"}}Mensaje firmado: 1791815400. seguido del cuerpo anterior. Para reproducir la firma desde una terminal:
printf '%s.%s' '1791815400' '{"event_id":"01J9ZK3Q7V4N8X2M5T6R1B0C9D","event_type":"order.accepted","timestamp":"2026-10-12T14:30:00Z","api_version":"v1","data":{"id":"9b6f0c3e-1a2d-4c55-8f01-3d2a7e6b9a10","reference":"OV-001234"}}' \ | openssl dgst -sha256 -hmac 'secreto-de-prueba' -hexErrores frecuentes
Sección titulada «Errores frecuentes»| Síntoma | Causa probable |
|---|---|
| La firma nunca coincide | Se está verificando sobre el JSON reserializado y no sobre el cuerpo crudo. |
| La firma nunca coincide | El secreto es de otra suscripción o tiene espacios o saltos de línea al inicio o al final. |
| La firma nunca coincide | El mensaje no usa el formato {timestamp}.{cuerpo}, por ejemplo falta el punto. |
| Se rechazan envíos válidos | El reloj de su servidor está desfasado más de 300 segundos. |
| Los reintentos fallan, pero el primer envío pasó | Se almacenó la firma o la marca de tiempo del primer intento. Ambas cambian en cada intento: verifique siempre con las cabeceras de la petición actual. |
Siguiente paso
Sección titulada «Siguiente paso»Revise Entrega y reintentos para saber cómo debe responder su endpoint.