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.
createdandexpires(integers, Unix seconds) andkeyid(the string from the cabinet) are required.algis optional, but if present it must be"ed25519".nonceandtagare accepted and ignored. Any other parameter is rejected. - One signature. Exactly one label in
Signature-Input. Usesig1. - Lifetime.
expires - createdis at most 300 seconds.createdmay be at most 30 seconds in the future. The request must arrive beforeexpires. - Nonce.
X-Request-Nonceis unique per project for 5 minutes 30 seconds. A replay returns409 request_replayed. - Body.
Content-Digestmatches the SHA-256 of the same bytes that arrived in the request. Onlysha-256is accepted. - Key.
keyidis 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.
- Test vector. Does your function produce exactly the headers above? If not, the bug is in your signing code, not in the request.
- Body. Are the signed and the sent bytes the same? Common causes: the client re-serializes JSON, adds a newline or changes the encoding.
- Clock. Is the server time correct?
createdmore than 30 seconds in the future, or a request arriving afterexpires, is rejected. Enable NTP. - Path and host.
@pathhas no query and no domain, exactly as sent.@authorityis the lower-case host withouthttps://. A proxy that rewritesHostbreaks the signature. - Key. Is
keyidcopied 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 thekeyidprinted by the key generator with the cabinet. - Components. Are all six components in
Signature-Input, lower-case and double-quoted? Are the base lines in the same order as inSignature-Input? - Format.
Signature: sig1=:…:— colons on both sides, standard base64 with=, exactly 64 signature bytes. The label inSignaturematchesSignature-Input. - Nonce. 16–128 characters without spaces or commas. A reused nonce gives
409 request_replayed, not 401. - Lifetime.
expires - createdis 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.
- Generate a new pair and upload the public key in the cabinet; it becomes "rotating".
- Switch your servers to the new private key and the new
keyid. - 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"
}
}