Ir al contenido

API

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

  1. Analiza X-Webhook-Signature: extrae t (la marca temporal) y todos los valores v1
  2. Rechaza si |now - t| > 300 segundos
  3. Compón signature_payload = str(t) + "." + raw_body
  4. Calcula HMAC-SHA256(webhook_secret, signature_payload)
  5. Convierte el resultado a hexadecimal en minúsculas
  6. Compáralo con cada valor v1 mediante una comparación de tiempo constante: cualquier coincidencia es válida
  7. Rechaza con HTTP 401 si 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);