На странице
Подпись
Проверяйте заголовок X-Webhook-Signature по HMAC-SHA256, безопасно сравнивайте подпись, контролируйте время и меняйте секрет без простоя.
Каждая доставка подписана HMAC-SHA256 секретом вашего вебхука. Проверьте подпись до обработки.
| Заголовок | Описание |
|---|---|
X-Webhook-Signature |
Составная подпись: t={timestamp},v1={hmac_hex} |
X-Webhook-Timestamp |
Unix-секунды этой попытки — то же значение, что t внутри подписи |
X-Webhook-Id |
Идентификатор evt_… этой доставки для журналов. В подпись не входит, поэтому дедуплицируйте по event_id из тела |
В проверке участвует только первый заголовок. Он содержит метку времени и одно или несколько значений HMAC:
X-Webhook-Signature: t=1739281200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
HMAC вычисляется над timestamp + "." + raw_body:
signature_payload = str(timestamp) + "." + raw_body
hmac_hex = HMAC-SHA256(webhook_secret, signature_payload) # lowercase hex
Метка времени входит в подписываемые данные — это защита от повторной отправки записанного запроса (replay). Отклоняйте доставки, у которых |now - timestamp| > 300 секунд.
Шаги проверки
- Разберите
X-Webhook-Signature: извлекитеt(метка времени) и все значенияv1 - Отклоните, если
|now - t| > 300секунд - Сформируйте
signature_payload = str(t) + "." + raw_body - Вычислите
HMAC-SHA256(webhook_secret, signature_payload) - Преобразуйте результат в hex-строку нижнего регистра
- Сравните с каждым значением
v1сравнением за постоянное время — совпадение с любым из них означает подлинность - Если ни одна подпись не совпала — ответьте HTTP
401
Всегда сравнивайте подписи функцией с постоянным временем — crypto.timingSafeEqual (Node), hmac.compare_digest (Python), hash_equals (PHP), MessageDigest.isEqual (Java), CryptographicOperations.FixedTimeEquals (.NET). Обычный == раскрывает секрет побайтно через время ответа.
Ротация секрета
При ротации секрета вебхука Paymos в течение 24 часов отправляет двойную подпись, чтобы вы могли перейти на новый секрет без простоя. Заголовок содержит два значения v1: t={timestamp},v1={current_hmac},v1={previous_hmac}. Подпись принимается, если совпадает либо с текущим, либо с предыдущим секретом, — поэтому ваша проверка (шаг 6) продолжает работать всю ротацию.
Если секрет утёк, эти сутки работают против вас. Пока идут 24 часа после ротации, в меню адреса в дашборде есть пункт Ротировать без льготного периода: он выдаёт новый секрет, а текущий и предыдущий тут же перестают подписывать доставки. С этого момента в заголовке одно значение v1, и ваша проверка отклоняет каждую доставку, пока сервер не перейдёт на новый секрет. Такие доставки повторяются по обычному расписанию, но обновить секрет лучше не откладывая. Если окна нет, сначала сделайте обычную ротацию — пункт появится после неё.
Код проверки
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);