# Проверка подписи

> Проверяйте заголовок X-Webhook-Signature по HMAC-SHA256, безопасно сравнивайте подпись, контролируйте время и меняйте секрет без простоя.

Каждая доставка подписана HMAC-SHA256 секретом вашего вебхука. Проверьте подпись до обработки.

| Заголовок | Описание |
|-----------|----------|
| `X-Webhook-Signature` | Составная подпись: `t={timestamp},v1={hmac_hex}` |
| `X-Webhook-Timestamp` | Unix-секунды этой попытки — то же значение, что `t` внутри подписи |
| `X-Webhook-Id` | Идентификатор `evt_…` этой доставки для журналов. В подпись не входит, поэтому [дедуплицируйте](/ru/docs/webhooks/payloads.md) по `event_id` из тела |

В проверке участвует только первый заголовок. Он содержит метку времени и одно или несколько значений HMAC:

```http
X-Webhook-Signature: t=1739281200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

HMAC вычисляется над `timestamp + "." + raw_body`:

```python
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`

:::danger
Всегда сравнивайте подписи функцией с постоянным временем — `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`, и ваша проверка отклоняет каждую доставку, пока сервер не перейдёт на новый секрет. Такие доставки повторяются по обычному расписанию, но обновить секрет лучше не откладывая. Если окна нет, сначала сделайте обычную ротацию — пункт появится после неё.

## Код проверки

```javascript
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);
```

```python
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
<?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();
```

```go
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
}
```

```csharp
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);
```

```java
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());
```

```ruby
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}"
```

```rust
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);
```
