En esta página
Firmas
Verifica X-Webhook-Signature con HMAC-SHA256, compara las firmas de forma segura, aplica las reglas de marca temporal y admite la rotación del secreto.
Cada entrega se firma con HMAC-SHA256 usando tu Webhook Secret. Verifica la firma antes de procesar.
| Cabecera | Descripción |
|---|---|
X-Webhook-Signature |
Firma compuesta: t={timestamp},v1={hmac_hex} |
X-Webhook-Timestamp |
Segundos Unix de este intento: el mismo valor que la firma lleva como t |
X-Webhook-Id |
El id evt_… de esta entrega, para tus registros y tu deduplicación |
En la verificación solo interviene la primera cabecera. Contiene la marca temporal y uno o más valores HMAC:
X-Webhook-Signature: t=1739281200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
El HMAC se calcula sobre timestamp + "." + raw_body:
signature_payload = str(timestamp) + "." + raw_body
hmac_hex = HMAC-SHA256(webhook_secret, signature_payload) # lowercase hex
La marca temporal forma parte del contenido firmado para evitar ataques de repetición. Rechaza las entregas en las que |now - timestamp| > 300 segundos.
Pasos de verificación
- Analiza
X-Webhook-Signature: extraet(la marca temporal) y todos los valoresv1 - Rechaza si
|now - t| > 300segundos - Compón
signature_payload = str(t) + "." + raw_body - Calcula
HMAC-SHA256(webhook_secret, signature_payload) - Convierte el resultado a hexadecimal en minúsculas
- Compáralo con cada valor
v1mediante una comparación de tiempo constante: cualquier coincidencia es válida - Rechaza con HTTP
401si ninguna firma coincide
Compara siempre las firmas con una función de tiempo constante: crypto.timingSafeEqual (Node), hmac.compare_digest (Python), hash_equals (PHP), MessageDigest.isEqual (Java), CryptographicOperations.FixedTimeEquals (.NET). Un == a secas filtra el secreto byte a byte mediante análisis de tiempos.
Rotación del secreto
Durante la rotación de un Webhook Secret, Paymos envía firmas dobles durante 24 horas para que migres sin interrupción. La cabecera lleva dos valores v1: t={timestamp},v1={current_hmac},v1={previous_hmac}. Se acepta cualquier firma que coincida con el secreto actual o con el anterior, así que tu comprobación existente (paso 6) sigue funcionando durante toda la rotación.
Código de verificación
import { WebhookVerifier } from '@paymos/sdk';
const verifier = new WebhookVerifier(process.env.PAYMOS_WEBHOOK_SECRET);
// Keep the body as a Buffer until verification succeeds.
const event = verifier.constructEvent(signatureHeader, rawBody);
console.log(event.eventId, event.eventType, event.data);
import os
from paymos import WebhookVerifier
verifier = WebhookVerifier(os.environ["PAYMOS_WEBHOOK_SECRET"])
# Keep raw_body as bytes until verification succeeds.
event = verifier.construct_event(signature_header, raw_body)
print(event["event_id"], event["event_type"], event["data"])
<?php
use Paymos\Webhook\WebhookEvent;
use Paymos\Webhook\WebhookVerifier;
$verifier = new WebhookVerifier(getenv('PAYMOS_WEBHOOK_SECRET'));
// Keep $rawBody unchanged until verification succeeds.
$payload = $verifier->decodeVerifiedPayload($signatureHeader, $rawBody);
$event = new WebhookEvent($payload);
echo $event->id() . ' ' . $event->type();
package main
import (
"fmt"
"os"
"time"
paymos "github.com/Paymos-labs/go-sdk/v2"
)
type invoiceEventData struct {
InvoiceID string `json:"invoice_id"`
}
func handleWebhook(signatureHeader string, rawBody []byte) error {
verifier, err := paymos.NewWebhookVerifier(os.Getenv("PAYMOS_WEBHOOK_SECRET"), 5*time.Minute)
if err != nil {
return err
}
var event paymos.WebhookEvent[invoiceEventData]
if err := verifier.ConstructEvent(signatureHeader, rawBody, time.Now(), &event); err != nil {
return err
}
fmt.Println(event.EventID, event.EventType, event.Data.InvoiceID)
return nil
}
using Paymos;
var verifier = new WebhookVerifier(
Environment.GetEnvironmentVariable("PAYMOS_WEBHOOK_SECRET")!);
// Keep rawBody as ReadOnlySpan<byte> until verification succeeds.
var webhook = verifier.ConstructEvent<InvoiceEventData>(signatureHeader, rawBody);
Console.WriteLine($"{webhook.EventId} {webhook.EventType} {webhook.Data.InvoiceId}");
public sealed record InvoiceEventData(string InvoiceId);
import com.fasterxml.jackson.databind.JsonNode;
import io.paymos.WebhookEvent;
import io.paymos.WebhookVerifier;
import java.time.Instant;
WebhookVerifier verifier = new WebhookVerifier(System.getenv("PAYMOS_WEBHOOK_SECRET"));
// Keep rawBody as byte[] until verification succeeds.
WebhookEvent<JsonNode> event =
verifier.constructEvent(signatureHeader, rawBody, Instant.now());
System.out.println(event.eventId() + " " + event.eventType() + " " + event.data());
require 'paymos'
verifier = Paymos::WebhookVerifier.new(ENV.fetch('PAYMOS_WEBHOOK_SECRET'))
# Keep raw_body as the original String until verification succeeds.
event = verifier.construct_event(signature_header, raw_body)
puts "#{event.event_id} #{event.event_type} #{event.data}"
use paymos::{Invoice, WebhookEvent, WebhookVerifier};
let verifier = WebhookVerifier::new(std::env::var("PAYMOS_WEBHOOK_SECRET")?)?;
// Keep raw_body as &[u8] until verification succeeds.
let event: WebhookEvent<Invoice> = verifier.construct_event(signature_header, raw_body)?;
println!("{} {} {}", event.event_id, event.event_type, event.data.invoice_id);