Перейти к содержимому
API
Открыть .md
На странице

Подпись

Проверяйте заголовок 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 секунд.

Шаги проверки

  1. Разберите X-Webhook-Signature: извлеките t (метка времени) и все значения v1
  2. Отклоните, если |now - t| > 300 секунд
  3. Сформируйте signature_payload = str(t) + "." + raw_body
  4. Вычислите HMAC-SHA256(webhook_secret, signature_payload)
  5. Преобразуйте результат в hex-строку нижнего регистра
  6. Сравните с каждым значением v1 сравнением за постоянное время — совпадение с любым из них означает подлинность
  7. Если ни одна подпись не совпала — ответьте 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);