Перейти к содержанию

Подпись запросов

Каждый изменяющий запрос к API подписывается вашим приватным ключом Ed25519 по стандарту RFC 9421 (HTTP Message Signatures). Сервер хранит только публичный ключ и проверяет подпись до того, как принять заявку. Так украденный токен без ключа не создаст ни одной заявки.

Подпись нужна всегда: в песочнице и в бою. Для чтения (GET) — только если в политике проекта включено signature_required; подписанный GET принимается всегда.

Не пишите подпись с нуля

Возьмите готовую функцию для своего языка ниже и прогоните её на проверочном примере. Если заголовки совпали байт в байт, подпись будет принята.

Какие заголовки добавить

Заголовок Значение
Content-Digest sha-256=:<base64(SHA-256 от байтов тела)>:. Для GET — дайджест пустого тела
X-Request-ID Ваш идентификатор запроса: 1–128 символов A-Za-z0-9._:-
X-Request-Nonce Одноразовая строка: 16–128 символов без пробелов, табуляций и запятых
Signature-Input sig1=(<компоненты>);created=…;expires=…;keyid="…";alg="ed25519"
Signature sig1=:<base64(64 байта подписи Ed25519)>:

И как обычно — Authorization: Bearer <токен> и Content-Type: application/json. Эти два заголовка не подписываются.

Профиль подписи

Сервер принимает подпись, только если выполнено всё:

  • Компоненты. Подпись покрывает все шесть: "@method", "@path", "@authority", "content-digest", "x-request-id", "x-request-nonce". Порядок любой. Можно добавить другие заголовки; другие производные компоненты ("@query", "@target-uri" и т. п.) не принимаются.
  • Параметры. Обязательны created и expires (целые, Unix-время в секундах) и keyid (строка из кабинета). alg необязателен, но если есть — только "ed25519". nonce и tag допускаются и не влияют на проверку. Другие параметры — отказ.
  • Одна подпись. В Signature-Input ровно одна метка. Используйте sig1.
  • Срок. expires - created не больше 300 секунд. created не может быть в будущем больше чем на 30 секунд. Запрос должен дойти до expires.
  • Nonce. X-Request-Nonce уникален в проекте 5 минут 30 секунд. Повтор — 409 request_replayed.
  • Тело. Content-Digest совпадает с SHA-256 тех же байтов, что пришли в запросе. Принимается только sha-256.
  • Ключ. keyid — рабочий ключ проекта или ключ на ротации.

Как строится база подписи

База подписи — это текст, который вы подписываете. Одна строка на компонент в том же порядке, что в Signature-Input, затем строка "@signature-params". Строки разделены \n, в конце перевода строки нет.

"@method": POST
"@path": /api/v1/payments
"@authority": api.example.com
"content-digest": sha-256=:7vp0UJ9PjprEvF8nY/Eh9EQV284GkSkTMxsEL6T6IBo=:
"x-request-id": 0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e
"x-request-nonce": 9f86d081884c7d659a2feaa0c55ad015
"@signature-params": ("@method" "@path" "@authority" "content-digest" "x-request-id" "x-request-nonce");created=1790596800;expires=1790597100;keyid="ed25519-If4x36FUomFia_hUBG_SJw";alg="ed25519"
Компонент Откуда значение
@method Метод в верхнем регистре: POST, GET
@path Путь URL в том виде, как он уходит в запрос, без query: /api/v1/payments
@authority Хост из {{base_url}} в нижнем регистре, с портом, только если он нестандартный: api.example.com
content-digest Значение заголовка Content-Digest
x-request-id Значение заголовка X-Request-ID как есть
x-request-nonce Значение заголовка X-Request-Nonce как есть
@signature-params То, что стоит после sig1= в Signature-Input

Подпишите байты базы в UTF-8 ключом Ed25519, получите 64 байта, закодируйте в base64 и поставьте в Signature: sig1=:…:.

Проверочный пример

Пример сгенерирован серверной реализацией проверки подписи и принят ею. Ed25519 детерминирован: ваша функция с теми же входными данными обязана выдать ровно эти заголовки. Все примеры кода на этой странице проверяются на нём автоматически.

Это тестовый ключ

Ключ взят из RFC 8032 (раздел 7.1, TEST 1) и известен всем. Не загружайте его в кабинет и не используйте нигде, кроме тестов.

Вход

Что Значение
Приватный ключ (seed, hex) 9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60
Приватный ключ (PEM) -----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIJ1hsZ3v/VpguoRK9JLsLMREScVpezJpGXA7rAMcrn9g
-----END PRIVATE KEY-----
Публичный ключ (base64) 11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=
keyid ed25519-If4x36FUomFia_hUBG_SJw
Запрос POST https://api.example.com/api/v1/payments — зарезервированный домен из RFC 2606. Хост входит в подпись, поэтому с вашим {{base_url}} подпись будет другой
created / expires 1790596800 / 1790597100 (2026-09-28 12:00:00 UTC + 300 с)
X-Request-Nonce 9f86d081884c7d659a2feaa0c55ad015
X-Request-ID 0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e

Тело — ровно эти байты, без перевода строки в конце:

{"merchant_payment_id":"order-1001","amount":"5000.00","currency":"RUB","geo_code":"RU","payment_method":"sbp","bank_code":"sber"}

Ожидаемый результат

Content-Digest: sha-256=:7vp0UJ9PjprEvF8nY/Eh9EQV284GkSkTMxsEL6T6IBo=:
Signature-Input: sig1=("@method" "@path" "@authority" "content-digest" "x-request-id" "x-request-nonce");created=1790596800;expires=1790597100;keyid="ed25519-If4x36FUomFia_hUBG_SJw";alg="ed25519"
Signature: sig1=:BtY8/iQPBfIpIsuvra/cgfiKFMF+tL0yjt4Y37FCM5aUtbGvo1F0pH1SDKp6QSc8Wo6Jj/+rv12KE5VLuZecAg==:

Дайджест пустого тела (для GET): sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:.

Пример в машиночитаемом виде — examples/test-vectors.json.

Готовые функции

Функция принимает метод, URL, байты тела, приватный ключ и keyid и возвращает заголовки подписи. Время, nonce и request ID она ставит сама; в тестах их можно зафиксировать.

OpenSSL 3.0 или новее (в macOS: brew install openssl@3). Функция печатает заголовки по одному в строке — передайте их в curl -H, как в скрипте создания заявки.

# Payment API request signing: RFC 9421, Ed25519.
# POSIX sh + OpenSSL 3.0 or newer (macOS: brew install openssl@3). Load with: . ./sign.sh

# api_content_digest FILE — Content-Digest of the exact file bytes.
api_content_digest() {
  printf 'sha-256=:%s:' "$(openssl dgst -sha256 -binary < "$1" | openssl base64 -A)"
}

# api_key_id PUBLIC_PEM — the keyid the cabinet shows for this public key:
# "ed25519-" + base64url(sha256(32 key bytes)[:16]).
api_key_id() {
  printf 'ed25519-%s' "$(openssl pkey -pubin -in "$1" -outform DER | tail -c 32 \
    | openssl dgst -sha256 -binary | head -c 16 | openssl base64 -A | tr '+/' '-_' | tr -d '=')"
}

# api_sign_request METHOD URL BODY_FILE KEY_FILE KEY_ID [CREATED NONCE REQUEST_ID]
# Prints the signature headers, one "Name: value" per line.
# BODY_FILE holds the exact bytes that will be sent (for GET use an empty file or /dev/null).
api_sign_request() {
  api_method=$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')
  api_rest=${2#*://}                                   # api.example.com/api/v1/payments?x=1
  api_authority=$(printf '%s' "${api_rest%%/*}" | tr '[:upper:]' '[:lower:]')
  api_path=/${api_rest#*/}; [ "$api_rest" = "${api_rest#*/}" ] && api_path=/
  api_path=${api_path%%\?*}                                # no query string
  api_created=${6:-$(date +%s)}
  api_nonce=${7:-$(openssl rand -hex 16)}
  api_request_id=${8:-$(openssl rand -hex 16)}
  api_digest=$(api_content_digest "$3")

  api_params="(\"@method\" \"@path\" \"@authority\" \"content-digest\" \"x-request-id\" \"x-request-nonce\")"
  api_params="$api_params;created=$api_created;expires=$((api_created + 300));keyid=\"$5\";alg=\"ed25519\""
  api_base_file=$(mktemp)
  printf '"@method": %s\n"@path": %s\n"@authority": %s\n"content-digest": %s\n"x-request-id": %s\n"x-request-nonce": %s\n"@signature-params": %s' \
    "$api_method" "$api_path" "$api_authority" "$api_digest" "$api_request_id" "$api_nonce" "$api_params" > "$api_base_file"
  api_signature=$(openssl pkeyutl -sign -rawin -inkey "$4" -in "$api_base_file" | openssl base64 -A)
  rm -f "$api_base_file"

  printf 'Content-Digest: %s\n' "$api_digest"
  printf 'X-Request-ID: %s\n' "$api_request_id"
  printf 'X-Request-Nonce: %s\n' "$api_nonce"
  printf 'Signature-Input: sig1=%s\n' "$api_params"
  printf 'Signature: sig1=:%s:\n' "$api_signature"
}

PHP 8.1+, расширение sodium (есть в стандартной сборке PHP).

<?php
// Payment API request signing: RFC 9421, Ed25519. PHP 8.1+, sodium extension.

declare(strict_types=1);

// Components the signature must cover. Any order, but the same as in Signature-Input.
const SIGNATURE_COMPONENTS = ['@method', '@path', '@authority', 'content-digest', 'x-request-id', 'x-request-nonce'];
const SIGNATURE_MAX_LIFETIME = 300; // expires - created must not exceed 5 minutes

/** RFC 9530 Content-Digest: sha-256 of the exact body bytes. */
function api_content_digest(string $body): string
{
    return 'sha-256=:' . base64_encode(hash('sha256', $body, true)) . ':';
}

/** The keyid the cabinet shows for a 32-byte public key. */
function api_key_id(string $publicKey): string
{
    $digest = substr(hash('sha256', $publicKey, true), 0, 16);
    return 'ed25519-' . rtrim(strtr(base64_encode($digest), '+/', '-_'), '=');
}

/**
 * Loads a PEM private key (PKCS#8, as produced by openssl genpkey)
 * and returns the 64-byte sodium secret key.
 */
function api_load_private_key(string $pem): string
{
    $der = base64_decode(preg_replace('/-----[^-]+-----|\s+/', '', $pem), true);
    // Ed25519 PKCS#8: a 16-byte header followed by the 32-byte seed.
    if ($der === false || strlen($der) !== 48 || !str_starts_with(bin2hex($der), '302e020100300506032b6570')) {
        throw new InvalidArgumentException('expected an Ed25519 private key in PEM (PKCS#8)');
    }
    $keypair = sodium_crypto_sign_seed_keypair(substr($der, 16));
    return sodium_crypto_sign_secretkey($keypair);
}

/**
 * Returns the signature headers. Add Authorization and Content-Type yourself.
 * $body must be the exact bytes that will be sent; '' for GET.
 *
 * @param array{created?: int, lifetime?: int, nonce?: string, request_id?: string} $options
 * @return array<string, string>
 */
function api_sign_request(
    string $method,
    string $url,
    string $body,
    string $secretKey,
    string $keyId,
    array $options = [],
): array {
    $parts = parse_url($url);
    $authority = strtolower($parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : ''));
    $created = $options['created'] ?? time();
    $lifetime = $options['lifetime'] ?? SIGNATURE_MAX_LIFETIME;
    $nonce = $options['nonce'] ?? bin2hex(random_bytes(16));
    $requestId = $options['request_id'] ?? bin2hex(random_bytes(16));
    $digest = api_content_digest($body);

    $values = [
        '@method' => strtoupper($method),
        '@path' => $parts['path'] ?? '/',
        '@authority' => $authority,
        'content-digest' => $digest,
        'x-request-id' => $requestId,
        'x-request-nonce' => $nonce,
    ];
    $quoted = implode(' ', array_map(fn (string $name): string => "\"$name\"", SIGNATURE_COMPONENTS));
    $params = sprintf('(%s);created=%d;expires=%d;keyid="%s";alg="ed25519"', $quoted, $created, $created + $lifetime, $keyId);
    $base = '';
    foreach (SIGNATURE_COMPONENTS as $name) {
        $base .= "\"$name\": {$values[$name]}\n";
    }
    $base .= "\"@signature-params\": $params";
    $signature = base64_encode(sodium_crypto_sign_detached($base, $secretKey));

    return [
        'Content-Digest' => $digest,
        'X-Request-ID' => $requestId,
        'X-Request-Nonce' => $nonce,
        'Signature-Input' => "sig1=$params",
        'Signature' => "sig1=:$signature:",
    ];
}

Python 3.10+, pip install cryptography.

"""Payment API request signing: RFC 9421, Ed25519.

Python 3.10+, the cryptography package (pip install cryptography).
"""
import base64
import hashlib
import secrets
import time
import uuid
from urllib.parse import urlsplit

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import load_pem_private_key

# Components the signature must cover. Any order, but the same as in Signature-Input.
COMPONENTS = ("@method", "@path", "@authority", "content-digest", "x-request-id", "x-request-nonce")
MAX_LIFETIME_SECONDS = 300  # expires - created must not exceed 5 minutes


def content_digest(body: bytes) -> str:
    """RFC 9530 Content-Digest: sha-256 of the exact body bytes."""
    return "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":"


def key_id(public_key: bytes) -> str:
    """The keyid the cabinet shows for a 32-byte public key."""
    digest = hashlib.sha256(public_key).digest()[:16]
    return "ed25519-" + base64.urlsafe_b64encode(digest).decode().rstrip("=")


def load_private_key(pem: bytes) -> Ed25519PrivateKey:
    """Loads a PEM private key (PKCS#8, as produced by openssl genpkey)."""
    key = load_pem_private_key(pem, password=None)
    if not isinstance(key, Ed25519PrivateKey):
        raise ValueError("expected an Ed25519 key")
    return key


def sign_request(
    method: str,
    url: str,
    body: bytes,
    private_key: Ed25519PrivateKey,
    key_id: str,
    *,
    created: int | None = None,
    lifetime: int = MAX_LIFETIME_SECONDS,
    nonce: str | None = None,
    request_id: str | None = None,
) -> dict[str, str]:
    """Returns the signature headers. Add Authorization and Content-Type yourself.

    body must be the exact bytes that will be sent; b"" for GET.
    """
    parts = urlsplit(url)
    created = int(time.time()) if created is None else created
    nonce = nonce or secrets.token_hex(16)
    request_id = request_id or str(uuid.uuid4())
    digest = content_digest(body)

    values = {
        "@method": method.upper(),
        "@path": parts.path or "/",
        "@authority": parts.netloc.lower(),
        "content-digest": digest,
        "x-request-id": request_id,
        "x-request-nonce": nonce,
    }
    params = (
        "(" + " ".join(f'"{name}"' for name in COMPONENTS) + ")"
        + f';created={created};expires={created + lifetime};keyid="{key_id}";alg="ed25519"'
    )
    base = "".join(f'"{name}": {values[name]}\n' for name in COMPONENTS)
    base += f'"@signature-params": {params}'
    signature = base64.b64encode(private_key.sign(base.encode())).decode()

    return {
        "Content-Digest": digest,
        "X-Request-ID": request_id,
        "X-Request-Nonce": nonce,
        "Signature-Input": f"sig1={params}",
        "Signature": f"sig1=:{signature}:",
    }

Node.js 20+, без зависимостей.

// Payment API request signing: RFC 9421, Ed25519. Node.js 20+, no dependencies.
import { createHash, createPrivateKey, randomBytes, randomUUID, sign } from 'node:crypto';

// Components the signature must cover. Any order, but the same as in Signature-Input.
const COMPONENTS = ['@method', '@path', '@authority', 'content-digest', 'x-request-id', 'x-request-nonce'];
const MAX_LIFETIME_SECONDS = 300; // expires - created must not exceed 5 minutes

/** RFC 9530 Content-Digest: sha-256 of the exact body bytes. */
export function contentDigest(body) {
  return `sha-256=:${createHash('sha256').update(body).digest('base64')}:`;
}

/** The keyid the cabinet shows for a 32-byte public key. */
export function keyId(publicKey) {
  return 'ed25519-' + createHash('sha256').update(publicKey).digest().subarray(0, 16).toString('base64url');
}

/** Loads a PEM private key (PKCS#8, as produced by openssl genpkey). */
export function loadPrivateKey(pem) {
  const key = createPrivateKey(pem);
  if (key.asymmetricKeyType !== 'ed25519') throw new Error('expected an Ed25519 key');
  return key;
}

/**
 * Returns the signature headers. Add Authorization and Content-Type yourself.
 * body must be the exact bytes (Buffer or string) that will be sent; '' for GET.
 */
export function signRequest(method, url, body, privateKey, keyIdValue, options = {}) {
  const { pathname, host } = new URL(url);
  const created = options.created ?? Math.floor(Date.now() / 1000);
  const lifetime = options.lifetime ?? MAX_LIFETIME_SECONDS;
  const nonce = options.nonce ?? randomBytes(16).toString('hex');
  const requestId = options.requestId ?? randomUUID();
  const digest = contentDigest(body);

  const values = {
    '@method': method.toUpperCase(),
    '@path': pathname || '/',
    '@authority': host.toLowerCase(),
    'content-digest': digest,
    'x-request-id': requestId,
    'x-request-nonce': nonce,
  };
  const params =
    `(${COMPONENTS.map((name) => `"${name}"`).join(' ')})` +
    `;created=${created};expires=${created + lifetime};keyid="${keyIdValue}";alg="ed25519"`;
  const base =
    COMPONENTS.map((name) => `"${name}": ${values[name]}\n`).join('') + `"@signature-params": ${params}`;
  const signature = sign(null, Buffer.from(base), privateKey).toString('base64');

  return {
    'Content-Digest': digest,
    'X-Request-ID': requestId,
    'X-Request-Nonce': nonce,
    'Signature-Input': `sig1=${params}`,
    Signature: `sig1=:${signature}:`,
  };
}

Go 1.22+, только стандартная библиотека.

// Package paymentapi is a payment API integration example using only the Go 1.22+ standard library.
package paymentapi

import (
    "crypto/ed25519"
    "crypto/rand"
    "crypto/sha256"
    "crypto/x509"
    "encoding/base64"
    "encoding/hex"
    "encoding/pem"
    "errors"
    "fmt"
    "net/http"
    "strings"
    "time"
)

// components the signature must cover. Any order, but the same as in Signature-Input.
var components = []string{"@method", "@path", "@authority", "content-digest", "x-request-id", "x-request-nonce"}

// MaxLifetime is the longest allowed window between created and expires.
const MaxLifetime = 5 * time.Minute

// SignOptions pins the time, nonce and request id. Empty fields are filled in automatically.
type SignOptions struct {
    Created   time.Time
    Lifetime  time.Duration
    Nonce     string
    RequestID string
}

// ContentDigest returns the RFC 9530 Content-Digest header: sha-256 of the exact body bytes.
func ContentDigest(body []byte) string {
    sum := sha256.Sum256(body)
    return "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":"
}

// KeyID returns the keyid the cabinet shows for this public key.
func KeyID(publicKey ed25519.PublicKey) string {
    sum := sha256.Sum256(publicKey)
    return "ed25519-" + base64.RawURLEncoding.EncodeToString(sum[:16])
}

// LoadPrivateKey reads a PEM private key (PKCS#8, as produced by openssl genpkey).
func LoadPrivateKey(pemBytes []byte) (ed25519.PrivateKey, error) {
    block, _ := pem.Decode(pemBytes)
    if block == nil {
        return nil, errors.New("paymentapi: key is not PEM")
    }
    parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
    if err != nil {
        return nil, fmt.Errorf("paymentapi: parse key: %w", err)
    }
    key, ok := parsed.(ed25519.PrivateKey)
    if !ok {
        return nil, errors.New("paymentapi: key is not Ed25519")
    }
    return key, nil
}

// SignRequest sets Content-Digest, X-Request-ID, X-Request-Nonce, Signature-Input and
// Signature on the request. body must be the exact bytes that will be sent; nil for GET.
// The caller sets Authorization and Content-Type.
func SignRequest(r *http.Request, body []byte, key ed25519.PrivateKey, keyID string, opts SignOptions) error {
    if opts.Created.IsZero() {
        opts.Created = time.Now()
    }
    if opts.Lifetime == 0 {
        opts.Lifetime = MaxLifetime
    }
    if opts.Nonce == "" {
        opts.Nonce = randomHex(16)
    }
    if opts.RequestID == "" {
        opts.RequestID = randomHex(16)
    }
    path := r.URL.EscapedPath()
    if path == "" {
        path = "/"
    }
    authority := r.Host
    if authority == "" {
        authority = r.URL.Host
    }
    digest := ContentDigest(body)
    values := map[string]string{
        "@method":         strings.ToUpper(r.Method),
        "@path":           path,
        "@authority":      strings.ToLower(authority),
        "content-digest":  digest,
        "x-request-id":    opts.RequestID,
        "x-request-nonce": opts.Nonce,
    }

    quoted := make([]string, len(components))
    var base strings.Builder
    for i, name := range components {
        quoted[i] = `"` + name + `"`
        base.WriteString(`"` + name + `": ` + values[name] + "\n")
    }
    created := opts.Created.Unix()
    params := fmt.Sprintf(`(%s);created=%d;expires=%d;keyid="%s";alg="ed25519"`,
        strings.Join(quoted, " "), created, created+int64(opts.Lifetime/time.Second), keyID)
    base.WriteString(`"@signature-params": ` + params)
    signature := ed25519.Sign(key, []byte(base.String()))

    r.Header.Set("Content-Digest", digest)
    r.Header.Set("X-Request-ID", opts.RequestID)
    r.Header.Set("X-Request-Nonce", opts.Nonce)
    r.Header.Set("Signature-Input", "sig1="+params)
    r.Header.Set("Signature", "sig1=:"+base64.StdEncoding.EncodeToString(signature)+":")
    return nil
}

func randomHex(n int) string {
    b := make([]byte, n)
    if _, err := rand.Read(b); err != nil {
        panic(err) // crypto/rand does not fail on supported platforms
    }
    return hex.EncodeToString(b)
}

Подписывайте и отправляйте одни и те же байты

Сериализуйте JSON один раз в строку или байты, подпишите их и отправьте их же. Не передавайте HTTP-клиенту объект, который он сериализует заново: порядок ключей, пробелы или экранирование могут измениться, и дайджест не совпадёт.

Чек-лист: 401 signature_invalid

Сервер не говорит, что именно не так: любая ошибка подписи выглядит одинаково. Проверьте по порядку.

  1. Проверочный пример. Ваша функция выдаёт ровно те заголовки, что выше? Если нет — ошибка в коде подписи, а не в запросе.
  2. Тело. Подписаны и отправлены одни и те же байты? Частая причина — клиент пересобирает JSON, добавляет перевод строки или меняет кодировку.
  3. Часы. Время сервера точное? created в будущем больше чем на 30 секунд или запрос пришёл после expires — отказ. Включите NTP.
  4. Путь и хост. @path без query и без домена, в том виде, как уходит в запрос. @authority — хост в нижнем регистре, без https://. Если запрос идёт через прокси, который меняет Host, подпись не совпадёт.
  5. Ключ. keyid скопирован из кабинета и принадлежит этому проекту? Ключ не отозван? Приватный ключ — пара к загруженному публичному? Сравните keyid из генератора ключа с кабинетом.
  6. Компоненты. Все шесть компонентов в Signature-Input, имена в нижнем регистре и в двойных кавычках? Порядок строк в базе такой же, как в Signature-Input?
  7. Формат. Signature: sig1=:…: — двоеточия по краям, стандартный base64 с =, ровно 64 байта подписи. Метка в Signature та же, что в Signature-Input.
  8. Nonce. 16–128 символов без пробелов и запятых. Повтор nonce даёт не 401, а 409 request_replayed.
  9. Срок. expires - created не больше 300.

Если всё сходится, а ответ всё ещё 401, пришлите в поддержку X-Request-ID запроса и время отправки.

Ротация ключа

У проекта один рабочий ключ и может быть один ключ на ротации. Пока идёт ротация, подписи принимаются обоими.

  1. Сгенерируйте новую пару и загрузите публичный ключ в кабинет — он встанет «на ротацию».
  2. Переключите серверы на новый приватный ключ и новый keyid.
  3. Когда все серверы перешли, активируйте новый ключ в кабинете. Старый отзовётся.

Если приватный ключ утёк, сразу отзовите его в кабинете и загрузите новый.

Машиночитаемый пример

Файл examples/test-vectors.json из репозитория документации — общий для всех языков:

{
  "_comment": "Общие проверочные примеры. Подпись сгенерирована серверной реализацией platform/httpsig (Sign) и проверена ею же (Verify). Ключ — тестовый ключ RFC 8032 §7.1 TEST 1, публично известен: не используйте его нигде, кроме тестов.",
  "signing": {
    "private_key_pem_file": "testdata/test-key.pem",
    "public_key_pem_file": "testdata/test-key.pub.pem",
    "seed_hex": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
    "public_key_b64": "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=",
    "key_id": "ed25519-If4x36FUomFia_hUBG_SJw",
    "method": "POST",
    "url": "https://api.example.com/api/v1/payments",
    "body": "{\"merchant_payment_id\":\"order-1001\",\"amount\":\"5000.00\",\"currency\":\"RUB\",\"geo_code\":\"RU\",\"payment_method\":\"sbp\",\"bank_code\":\"sber\"}",
    "created": 1790596800,
    "expires": 1790597100,
    "nonce": "9f86d081884c7d659a2feaa0c55ad015",
    "request_id": "0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e",
    "expected": {
      "content_digest": "sha-256=:7vp0UJ9PjprEvF8nY/Eh9EQV284GkSkTMxsEL6T6IBo=:",
      "signature_base": "\"@method\": POST\n\"@path\": /api/v1/payments\n\"@authority\": api.example.com\n\"content-digest\": sha-256=:7vp0UJ9PjprEvF8nY/Eh9EQV284GkSkTMxsEL6T6IBo=:\n\"x-request-id\": 0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e\n\"x-request-nonce\": 9f86d081884c7d659a2feaa0c55ad015\n\"@signature-params\": (\"@method\" \"@path\" \"@authority\" \"content-digest\" \"x-request-id\" \"x-request-nonce\");created=1790596800;expires=1790597100;keyid=\"ed25519-If4x36FUomFia_hUBG_SJw\";alg=\"ed25519\"",
      "signature_input": "sig1=(\"@method\" \"@path\" \"@authority\" \"content-digest\" \"x-request-id\" \"x-request-nonce\");created=1790596800;expires=1790597100;keyid=\"ed25519-If4x36FUomFia_hUBG_SJw\";alg=\"ed25519\"",
      "signature": "sig1=:BtY8/iQPBfIpIsuvra/cgfiKFMF+tL0yjt4Y37FCM5aUtbGvo1F0pH1SDKp6QSc8Wo6Jj/+rv12KE5VLuZecAg==:",
      "empty_body_digest": "sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:"
    }
  },
  "callback": {
    "secret": "whsec_MfKQ9r2vXz",
    "previous_secret": "whsec_previous",
    "timestamp": "1700000000",
    "body": "{\"id\":\"3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42\",\"event_id\":\"5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21\",\"amount\":3400,\"initial_amount\":3400,\"status\":\"completed\",\"currency\":\"RUB\",\"payment_method\":\"sbp\",\"bank_code\":\"sber\",\"merchant_payment_id\":\"order-42\",\"rate\":11.00,\"course\":100.00,\"is_intrabank\":false,\"is_test\":false,\"project_id\":\"5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b\"}",
    "expected_signature": "e3e688bac4039def6f680c16f14701866d8e5e2de66d9b37f9ecc8bccb90220f",
    "expected_previous_signature": "54b9a97acdb1adf27f81bc49bdc9d963b4fe4f3bf5114ea3303581084e99f3b4"
  }
}