Skip to content

Request signing

Every write request to the API is signed with your Ed25519 private key per RFC 9421 (HTTP Message Signatures). The server stores only your public key and checks the signature before it accepts a payment. A stolen token without the key cannot create a single payment.

A signature is always required, in the sandbox and in production. Reads (GET) need it only when the project policy has signature_required; a signed GET is always accepted.

Do not write signing from scratch

Take the ready-made function for your language below and run it on the test vector. If the headers match byte for byte, the server will accept your signatures.

Headers to add

Header Value
Content-Digest sha-256=:<base64(SHA-256 of the body bytes)>:. For GET, the digest of an empty body
X-Request-ID Your request id: 1–128 characters A-Za-z0-9._:-
X-Request-Nonce A one-time string: 16–128 characters, no spaces, tabs or commas
Signature-Input sig1=(<components>);created=…;expires=…;keyid="…";alg="ed25519"
Signature sig1=:<base64(64-byte Ed25519 signature)>:

Plus, as usual, Authorization: Bearer <token> and Content-Type: application/json. These two are not signed.

Signature profile

The server accepts a signature only if all of this holds:

  • Components. The signature covers all six: "@method", "@path", "@authority", "content-digest", "x-request-id", "x-request-nonce". Any order. Extra header components are allowed; other derived components ("@query", "@target-uri", …) are not.
  • Parameters. created and expires (integers, Unix seconds) and keyid (the string from the cabinet) are required. alg is optional, but if present it must be "ed25519". nonce and tag are accepted and ignored. Any other parameter is rejected.
  • One signature. Exactly one label in Signature-Input. Use sig1.
  • Lifetime. expires - created is at most 300 seconds. created may be at most 30 seconds in the future. The request must arrive before expires.
  • Nonce. X-Request-Nonce is unique per project for 5 minutes 30 seconds. A replay returns 409 request_replayed.
  • Body. Content-Digest matches the SHA-256 of the same bytes that arrived in the request. Only sha-256 is accepted.
  • Key. keyid is the project's active key or the key in rotation.

How the signature base is built

The signature base is the text you sign: one line per component, in the same order as in Signature-Input, then the "@signature-params" line. Lines are separated by \n; there is no trailing newline.

"@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"
Component Value
@method Upper-case method: POST, GET
@path The URL path exactly as sent, without the query: /api/v1/payments
@authority Lower-case host of {{base_url}}, with a port only if it is non-default: api.example.com
content-digest The Content-Digest header value
x-request-id The X-Request-ID header value as is
x-request-nonce The X-Request-Nonce header value as is
@signature-params Everything after sig1= in Signature-Input

Sign the UTF-8 bytes of the base with Ed25519, take the 64 bytes, encode them in base64 and put them in Signature: sig1=:…:.

Test vector

This vector was produced by the server's own signature code and accepted by it. Ed25519 is deterministic: given the same input, your function must produce exactly these headers. Every code sample on this page is checked against it automatically.

This is a test key

The key comes from RFC 8032 (section 7.1, TEST 1) and is public. Never upload it to the cabinet or use it anywhere except tests.

Input

What Value
Private key (seed, hex) 9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60
Private key (PEM) -----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIJ1hsZ3v/VpguoRK9JLsLMREScVpezJpGXA7rAMcrn9g
-----END PRIVATE KEY-----
Public key (base64) 11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=
keyid ed25519-If4x36FUomFia_hUBG_SJw
Request POST https://api.example.com/api/v1/payments, a domain reserved by RFC 2606. The host is signed, so with your {{base_url}} the signature differs
created / expires 1790596800 / 1790597100 (2026-09-28 12:00:00 UTC + 300 s)
X-Request-Nonce 9f86d081884c7d659a2feaa0c55ad015
X-Request-ID 0b6d2c4e-8f1a-4d3b-9e7c-5a2f1b3c4d5e

The body is exactly these bytes, with no trailing newline:

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

Expected output

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==:

Empty-body digest (for GET): sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:.

The machine-readable vector is examples/test-vectors.json.

Ready-made functions

The function takes the method, URL, body bytes, private key and keyid and returns the signature headers. It fills in the time, nonce and request id itself; tests can pin them.

OpenSSL 3.0 or newer (macOS: brew install openssl@3). The function prints one header per line — pass them to curl -H as the payment script does.

# 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+, the sodium extension (bundled with standard PHP builds).

<?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+, no dependencies.

// 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+, standard library only.

// 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)
}

Sign and send the same bytes

Serialize the JSON once into a string or bytes, sign them and send them. Do not hand the HTTP client an object that it serializes again: key order, whitespace or escaping may change and the digest will no longer match.

Checklist: 401 signature_invalid

The server does not say what exactly is wrong: every signature failure looks the same. Check in this order.

  1. Test vector. Does your function produce exactly the headers above? If not, the bug is in your signing code, not in the request.
  2. Body. Are the signed and the sent bytes the same? Common causes: the client re-serializes JSON, adds a newline or changes the encoding.
  3. Clock. Is the server time correct? created more than 30 seconds in the future, or a request arriving after expires, is rejected. Enable NTP.
  4. Path and host. @path has no query and no domain, exactly as sent. @authority is the lower-case host without https://. A proxy that rewrites Host breaks the signature.
  5. Key. Is keyid copied from the cabinet and does it belong to this project? Is the key not revoked? Is the private key the pair of the uploaded public key? Compare the keyid printed by the key generator with the cabinet.
  6. Components. Are all six components in Signature-Input, lower-case and double-quoted? Are the base lines in the same order as in Signature-Input?
  7. Format. Signature: sig1=:…: — colons on both sides, standard base64 with =, exactly 64 signature bytes. The label in Signature matches Signature-Input.
  8. Nonce. 16–128 characters without spaces or commas. A reused nonce gives 409 request_replayed, not 401.
  9. Lifetime. expires - created is at most 300.

If everything matches and you still get 401, send support the request's X-Request-ID and the time it was sent.

Key rotation

A project has one active key and may have one key in rotation. During rotation, both verify.

  1. Generate a new pair and upload the public key in the cabinet; it becomes "rotating".
  2. Switch your servers to the new private key and the new keyid.
  3. Once all servers use it, activate the new key in the cabinet. The old one is revoked.

If a private key leaks, revoke it in the cabinet immediately and upload a new one.

Machine-readable vector

examples/test-vectors.json from the docs repository, shared by all languages:

{
  "_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"
  }
}