# Request signing

Every write request to the API is signed with your Ed25519 private key per
[RFC 9421 (HTTP Message Signatures)](https://www.rfc-editor.org/rfc/rfc9421).
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.

!!! tip "Do not write signing from scratch"
    Take the ready-made function for your language [below](#ready-functions) and run it
    on the [test vector](#test-vector). If the headers match byte for byte, the server will
    accept your signatures.

## Headers to add { #headers }

| 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 { #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 { #signature-base }

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.

```text
"@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 { #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.

!!! danger "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-----`<br>`MC4CAQAwBQYDK2VwBCIEIJ1hsZ3v/VpguoRK9JLsLMREScVpezJpGXA7rAMcrn9g`<br>`-----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:

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

**Expected output**

```http
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`](#test-vector-json).

## Ready-made functions { #ready-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.

=== "curl"

    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](quickstart.md#first-payment)
    does.

    ```bash
    # 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"

    PHP 8.1+, the `sodium` extension (bundled with standard PHP builds).

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

    Python 3.10+, `pip install cryptography`.

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

    Node.js 20+, no dependencies.

    ```javascript
    // 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"

    Go 1.22+, standard library only.

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

!!! warning "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` { #debug-checklist }

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](#test-vector)?
   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 { #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 { #test-vector-json }

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

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

```