Skip to content

Request Signing ​

ADOPAY open gateway APIs — including Account, cashin, and cashout endpoints under both the Merchant API and Partner API — use ES256 (ECDSA P-256) request signatures to verify request integrity and authenticity. During onboarding, each integrator generates a P-256 key pair: the public key is uploaded to Adopay for server-side verification, while the private key is kept by the integrator and signs every request. Responses and webhook notifications returned by the platform are signed with the same scheme, so integrators can verify them with the platform public key (see section 5).

1. Signature headers ​

All of the following headers are required on every gateway open-API call; missing any one causes rejection:

HeaderDescription
X-Merchant-IdMerchant number (top-level merchant number). The server uses it to locate the merchant's verification public key configuration.
X-TimestampUnix timestamp in seconds, digits only (do not send milliseconds). The server checks the offset from the current time, ±300 seconds by default.
X-NonceAnti-replay nonce; a UUID v4 or a random hex string of 32+ characters is recommended. Must not repeat under the same merchant number within the validity window (300 seconds by default).
DigestRequest body digest in the form SHA-256=<Base64(SHA-256(raw body bytes))>. For bodyless requests such as GET, hash the empty byte string, which always yields SHA-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=.
AuthorizationES256 signature information in the fixed format described in section 4. keyId is the key version registered with Adopay; it stays v1 unless keys have been rotated.

2. Key requirements ​

ItemRequirement
AlgorithmECDSA P-256 (ES256)
Private keyGenerated and kept by the integrator; SEC1 (EC PRIVATE KEY) or PKCS8 (PRIVATE KEY) PEM supported
Public keyUploaded to Adopay for verification; PUBLIC KEY PEM
Key versionIdentified by keyId in Authorization, used for key rotation (see section 8)

The private key is a sensitive credential: server-side only, never in client code, logs, or public repositories.

3. Build the canonical string ​

The canonical string is the definitive text of the content to be signed for this request. It consists of four lines in a fixed order joined with \n, with no trailing newline:

text
(request-target): <lowercase HTTP method> <path>[?<raw query>]
x-timestamp: <timestamp>
x-nonce: <nonce>
digest: <value of the Digest header>

Construction has two steps:

Step 1: compute the request body digest (Digest). Compute SHA-256 over the raw request body bytes actually sent, then apply standard Base64 encoding to get SHA-256=<Base64>. For bodyless requests such as GET or DELETE, compute over the empty byte string, which always yields SHA-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=.

Step 2: assemble the four-line canonical string. Rules:

  1. The (request-target) line: the HTTP method in lowercase (e.g. post), the path starting with / and being the raw path actually sent; when there is a query, use the raw query string actually sent (not re-sorted or re-encoded), byte for byte identical to the final URL; when there is no query, omit ? and everything after it.
  2. The values of the x-timestamp and x-nonce lines are exactly the X-Timestamp and X-Nonce header values.
  3. The digest line carries the full SHA-256=<Base64> value.

After signing, do not re-format or re-serialize the request body (for example, do not reorder JSON keys, re-minify, or change escaping); otherwise the server recomputes the digest over the raw bytes and it will never match.

Example:

text
(request-target): post /cashin/pix/create-qrcode
x-timestamp: 1790200000
x-nonce: 550e8400-e29b-41d4-a716-446655440000
digest: SHA-256=hxLlwyZ8YdkeKVEmKUMSxnDkPaDnj9LAp8+jwyqwRaI=

GET example with a query (no body, digest over the empty byte string):

text
(request-target): get /cashin/order/query?merchantOrderNo=20260819000123&platOrderNo=3KITxA1dEXhZ2IRXvwck
x-timestamp: 1790200001
x-nonce: 6ba7b810-9dad-11d1-80b4-00c04fd430c8
digest: SHA-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=

4. Generate the signature value ​

Sign the canonical string built in section 3 with the integrator's P-256 private key and assemble the result into the Authorization header in three steps:

Step 1: sign with the private key. Use the ES256 algorithm (ECDSA over the SHA-256 hash of the canonical string), producing an ASN.1/DER-encoded signature.

Step 2: Base64-encode. Apply standard Base64 encoding to the DER signature (with + / =, not Base64URL).

Step 3: assemble the Authorization header. In the fixed format:

text
Signature keyId="<key version>",alg="ES256",headers="(request-target) x-timestamp x-nonce digest",signature="<Base64 signature>"
  • keyId: the key version registered with Adopay, v1 by default; the server locates the exact public key by X-Merchant-Id + keyId.
  • alg must be exactly ES256 and headers must be exactly (request-target) x-timestamp x-nonce digest; the server compares both fields verbatim to prevent signature downgrade or missing signed fields.
  • Parameters are separated by commas without spaces, and values are wrapped in double quotes.

Once assembled, send it together with the remaining headers from section 1. See section 7 for complete code.

5. Response and webhook verification ​

All responses Adopay returns to the caller — including responses that failed verification or business processing — as well as webhook notifications, are signed with the same scheme used for requests. Integrators should verify them with the platform public key to confirm that responses and notifications really come from Adopay and have not been tampered with.

Obtain the platform public key ​

The platform public key is provided with the account opening materials in PUBLIC KEY PEM format; Sandbox and production are independent. Platform signatures identify the key version through keyId in Authorization (webhooks additionally carry the X-Secret-Key-Version header). When Adopay rotates its signing keys, they are marked with a new version number; select the matching platform public key by keyId and do not assume the platform has only one version.

Response headers ​

HeaderDescription
X-Merchant-IdThe platform signing identity (not the merchant number here).
X-Timestamp / X-NonceTimestamp and nonce for this response (not the request's original values).
DigestResponse body digest, computed over the raw response body bytes.
AuthorizationPlatform ES256 signature.
Authorization.keyIdPlatform signing key identifier, used for platform key rotation.

Build the verification string ​

The rules are the same as the canonical string in section 3, but the values come from different sources:

  1. The (request-target) line reuses this request's lowercase method, path, and raw query (it is not the response status line).
  2. The x-timestamp, x-nonce, and digest lines take the response header values, with digest computed over the raw response body bytes.

Verification flow ​

  1. Recompute SHA-256 over the response body raw bytes and compare it verbatim with the response Digest header; do not deserialize the JSON and re-serialize it first.
  2. Check that X-Timestamp is within the allowed time window and that X-Nonce has not appeared before (anti-replay).
  3. Parse the signature in the response Authorization, locate the platform public key by keyId, rebuild the verification string per the previous subsection, and verify the ES256 signature (DER signature, standard Base64 decoding).

If any step fails, do not trust the response; log it and investigate. Common causes: the HTTP client re-encodes the response body while reading it (changing the raw bytes), or the system clock drifts beyond the time window. The verification implementation can directly reuse the canonical-string building logic from the language examples in section 7, substituting the values with the corresponding response header fields.

Webhook notification verification ​

Webhooks are delivered proactively by Adopay. The (request-target) line of the verification string uses this notification's own lowercase method (post), callback path, and raw query; the remaining rules are the same as response verification. For complete steps and a Python verification example, see section 4 of Webhook Specification.

In addition, the gateway transport layer always returns HTTP 200; business success or failure is determined by the business fields in the response body (see Response Status).

6. Server-side verification pipeline ​

The server verifies each request in the following order and rejects it at the first failure (the business status in the response body is a non-success code, typically 1003 signature verification failed or 1004 parameter validation failed):

  1. Required headers: all five headers — X-Merchant-Id, X-Timestamp, X-Nonce, Digest, Authorization — must be present.
  2. Nonce anti-replay: a given merchant number + X-Nonce pair is accepted only the first time it appears within the validity window (300 seconds by default); repeats are rejected outright.
  3. Timestamp check: X-Timestamp must be a valid Unix second-level timestamp within ±300 seconds of the current time.
  4. Digest check: the server reads the raw request body bytes, recomputes SHA-256, and compares it verbatim with the Digest header.
  5. Authorization parsing and algorithm check: parses the keyId / alg / headers / signature parameters; alg must be ES256 and headers must be exactly (request-target) x-timestamp x-nonce digest.
  6. Public key lookup: finds the merchant public key registered with Adopay by X-Merchant-Id + keyId.
  7. Signature verification: rebuilds the canonical string per section 3 and verifies the ES256 signature (DER signature, standard Base64 decoding) with the merchant public key.

Understanding this order helps debugging: for example, a Digest mismatch always means the request body bytes changed (re-serialization, encoding, proxy rewrite) and has nothing to do with the private key.

7. Integration examples ​

Click a tab to switch languages. In each example the functions are arranged in call order: the entry point buildSignedHeaders assembles all required headers from section 1 (body is the raw bytes actually sent), it internally calls signRequest to produce digest and authorization, and the digest and canonical-string helper functions complete the low-level computation.

go
// Go 1.13+, standard library only; both SEC1 (EC PRIVATE KEY) and PKCS8 (PRIVATE KEY) PEM keys work.
import (
	"crypto/ecdsa"
	"crypto/rand"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/hex"
	"encoding/pem"
	"fmt"
	"strings"
	"time"
)

// buildSignedHeaders assembles all signature headers for one request (body = exact bytes sent).
func buildSignedHeaders(merchantID string, body, privateKeyPEM []byte) (map[string]string, error) {
	timestamp := fmt.Sprintf("%d", time.Now().Unix()) // Unix seconds
	nonceBuf := make([]byte, 16)
	if _, err := rand.Read(nonceBuf); err != nil {
		return nil, err
	}
	nonce := hex.EncodeToString(nonceBuf) // 32 random hex characters

	digest, authorization, err := signRequest(
		"POST", "/cashin/pix/create-qrcode", "", timestamp, nonce, body, "v1", privateKeyPEM)
	if err != nil {
		return nil, err
	}
	return map[string]string{
		"X-Merchant-Id": merchantID,
		"X-Timestamp":   timestamp,
		"X-Nonce":       nonce,
		"Digest":        digest,
		"Authorization": authorization,
	}, nil
}

// signRequest produces the Digest and Authorization with the merchant P-256 private key.
// The key may be SEC1 (EC PRIVATE KEY) or PKCS8 (PRIVATE KEY) PEM.
func signRequest(method, path, rawQuery, timestamp, nonce string, body []byte,
	keyID string, privateKeyPEM []byte) (string, string, error) {

	digest := buildDigest(body)
	canonical := buildCanonicalString(method, path, rawQuery, timestamp, nonce, digest)

	block, _ := pem.Decode(privateKeyPEM)
	if block == nil {
		return "", "", fmt.Errorf("invalid private key pem")
	}
	var key *ecdsa.PrivateKey
	if k, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
		key = k // SEC1
	} else {
		parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) // PKCS8
		if err != nil {
			return "", "", err
		}
		k, ok := parsed.(*ecdsa.PrivateKey)
		if !ok {
			return "", "", fmt.Errorf("private key is not EC P-256")
		}
		key = k
	}

	hash := sha256.Sum256([]byte(canonical))
	signature, err := ecdsa.SignASN1(rand.Reader, key, hash[:]) // DER encoded
	if err != nil {
		return "", "", err
	}
	authorization := fmt.Sprintf(
		`Signature keyId="%s",alg="ES256",headers="(request-target) x-timestamp x-nonce digest",signature="%s"`,
		keyID, base64.StdEncoding.EncodeToString(signature))
	return digest, authorization, nil
}

// buildCanonicalString joins the four canonical lines in fixed order (no trailing newline).
func buildCanonicalString(method, path, rawQuery, timestamp, nonce, digest string) string {
	requestTarget := strings.ToLower(method) + " " + path
	if rawQuery != "" {
		requestTarget += "?" + strings.TrimPrefix(rawQuery, "?")
	}
	return strings.Join([]string{
		"(request-target): " + requestTarget,
		"x-timestamp: " + timestamp,
		"x-nonce: " + nonce,
		"digest: " + digest,
	}, "\n")
}

// buildDigest computes the Digest header over the raw body bytes.
func buildDigest(body []byte) string {
	sum := sha256.Sum256(body)
	return "SHA-256=" + base64.StdEncoding.EncodeToString(sum[:])
}
java
// Java 8+, standard library only; the private key must be PKCS8 format.
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

public class GatewaySigner {

    // Assembles all signature headers for one request (body = exact bytes sent)
    static Map<String, String> buildSignedHeaders(String merchantId, byte[] body,
                                                  byte[] privateKeyPem) throws Exception {
        String timestamp = String.valueOf(Instant.now().getEpochSecond()); // Unix seconds
        String nonce = UUID.randomUUID().toString();

        String[] r = signRequest("POST", "/cashin/pix/create-qrcode", "",
                timestamp, nonce, body, "v1", privateKeyPem);
        Map<String, String> headers = new HashMap<>();
        headers.put("X-Merchant-Id", merchantId);
        headers.put("X-Timestamp", timestamp);
        headers.put("X-Nonce", nonce);
        headers.put("Digest", r[0]);
        headers.put("Authorization", r[1]);
        return headers;
    }

    // Produces the Digest and Authorization with the merchant P-256 private key
    static String[] signRequest(String method, String path, String rawQuery,
                                String timestamp, String nonce, byte[] body,
                                String keyId, byte[] privateKeyPem) throws Exception {
        String digest = buildDigest(body);

        String requestTarget = method.toLowerCase() + " " + path;
        if (rawQuery != null && !rawQuery.isEmpty()) {
            requestTarget += "?" + (rawQuery.startsWith("?") ? rawQuery.substring(1) : rawQuery);
        }
        String canonical = String.join("\n",
                "(request-target): " + requestTarget,
                "x-timestamp: " + timestamp,
                "x-nonce: " + nonce,
                "digest: " + digest);

        String pem = new String(privateKeyPem, StandardCharsets.UTF_8)
                .replace("-----BEGIN PRIVATE KEY-----", "")
                .replace("-----END PRIVATE KEY-----", "")
                .replaceAll("\\s", "");
        byte[] der = Base64.getDecoder().decode(pem);
        PrivateKey key = KeyFactory.getInstance("EC")
                .generatePrivate(new PKCS8EncodedKeySpec(der));

        Signature signer = Signature.getInstance("SHA256withECDSA"); // DER encoded output
        signer.initSign(key);
        signer.update(canonical.getBytes(StandardCharsets.UTF_8));
        String signature = Base64.getEncoder().encodeToString(signer.sign());

        String authorization = "Signature keyId=\"" + keyId + "\",alg=\"ES256\","
                + "headers=\"(request-target) x-timestamp x-nonce digest\","
                + "signature=\"" + signature + "\"";
        return new String[]{digest, authorization};
    }

    // Computes the Digest header over the raw body bytes
    static String buildDigest(byte[] body) throws Exception {
        byte[] hash = MessageDigest.getInstance("SHA-256").digest(body);
        return "SHA-256=" + Base64.getEncoder().encodeToString(hash);
    }
}
js
// Node.js 14+, built-in node:crypto only; both SEC1 and PKCS8 private keys work.
import crypto from "node:crypto";

// Assembles all signature headers for one request (body = exact bytes sent)
function buildSignedHeaders({ merchantId, body, privateKeyPem }) {
  const timestamp = String(Math.floor(Date.now() / 1000)); // Unix seconds
  const nonce = crypto.randomUUID(); // Node 14.17+; use a UUID library on older versions

  const { digest, authorization } = signRequest({
    method: "POST",
    path: "/cashin/pix/create-qrcode",
    rawQuery: "",
    timestamp,
    nonce,
    body,
    keyId: "v1",
    privateKeyPem,
  });
  return {
    "X-Merchant-Id": merchantId,
    "X-Timestamp": timestamp,
    "X-Nonce": nonce,
    "Digest": digest,
    "Authorization": authorization,
  };
}

// Produces the Digest and Authorization with the merchant P-256 private key
function signRequest({ method, path, rawQuery, timestamp, nonce, body, keyId, privateKeyPem }) {
  const digest = buildDigest(body);

  let requestTarget = `${method.toLowerCase()} ${path}`;
  if (rawQuery) {
    requestTarget += `?${rawQuery.replace(/^\?/, "")}`;
  }
  const canonical = [
    `(request-target): ${requestTarget}`,
    `x-timestamp: ${timestamp}`,
    `x-nonce: ${nonce}`,
    `digest: ${digest}`,
  ].join("\n");

  // ECDSA output is DER encoded; Base64 gives the signature value
  const signature = crypto
    .createSign("SHA256")
    .update(canonical, "utf8")
    .sign(privateKeyPem, "base64");

  const authorization =
    `Signature keyId="${keyId}",alg="ES256",` +
    `headers="(request-target) x-timestamp x-nonce digest",` +
    `signature="${signature}"`;
  return { digest, authorization };
}

// Computes the Digest header over the raw body bytes
function buildDigest(body) {
  return "SHA-256=" + crypto.createHash("sha256").update(body).digest("base64");
}
php
<?php
// PHP 7.1+ with the openssl extension; both SEC1 and PKCS8 private keys work.

// Assembles all signature headers for one request (body = exact bytes sent)
function buildSignedHeaders(string $merchantId, string $body, string $privateKeyPem): array
{
    $timestamp = (string) time(); // Unix seconds
    $nonce = bin2hex(random_bytes(16)); // 32 random hex characters

    [$digest, $authorization] = signRequest(
        'POST', '/cashin/pix/create-qrcode', '', $timestamp, $nonce, $body, 'v1', $privateKeyPem
    );

    return [
        'X-Merchant-Id' => $merchantId,
        'X-Timestamp'   => $timestamp,
        'X-Nonce'       => $nonce,
        'Digest'        => $digest,
        'Authorization' => $authorization,
    ];
}

// Produces the Digest and Authorization with the merchant P-256 private key
function signRequest(
    string $method,
    string $path,
    string $rawQuery,
    string $timestamp,
    string $nonce,
    string $body,
    string $keyId,
    string $privateKeyPem
): array {
    $digest = buildDigest($body);

    $requestTarget = strtolower($method) . ' ' . $path;
    if ($rawQuery !== '') {
        $requestTarget .= '?' . ltrim($rawQuery, '?');
    }
    $canonical = implode("\n", [
        '(request-target): ' . $requestTarget,
        'x-timestamp: ' . $timestamp,
        'x-nonce: ' . $nonce,
        'digest: ' . $digest,
    ]);

    $privateKey = openssl_pkey_get_private($privateKeyPem);
    if ($privateKey === false) {
        throw new RuntimeException('invalid private key: ' . openssl_error_string());
    }
    // openssl_sign produces a DER-encoded signature for EC keys, as the gateway requires
    if (!openssl_sign($canonical, $signature, $privateKey, OPENSSL_ALGO_SHA256)) {
        throw new RuntimeException('openssl_sign failed: ' . openssl_error_string());
    }

    $authorization = sprintf(
        'Signature keyId="%s",alg="ES256",headers="(request-target) x-timestamp x-nonce digest",signature="%s"',
        $keyId,
        base64_encode($signature)
    );

    return [$digest, $authorization];
}

// Computes the Digest header over the raw body bytes
function buildDigest(string $body): string
{
    return 'SHA-256=' . base64_encode(hash('sha256', $body, true));
}
python
# Python 3.7+; requires cryptography (pip install cryptography).
import base64
import hashlib
import time
import uuid

from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec


# Assembles all signature headers for one request (body = exact bytes sent)
def build_signed_headers(merchant_id, body, private_key_pem):
    timestamp = str(int(time.time()))  # Unix seconds
    nonce = str(uuid.uuid4())

    digest, authorization = sign_request(
        "POST", "/cashin/pix/create-qrcode", "", timestamp, nonce,
        body, "v1", private_key_pem,
    )
    return {
        "X-Merchant-Id": merchant_id,
        "X-Timestamp": timestamp,
        "X-Nonce": nonce,
        "Digest": digest,
        "Authorization": authorization,
    }


# Produces the Digest and Authorization with the merchant P-256 private key
def sign_request(method, path, raw_query, timestamp, nonce, body, key_id, private_key_pem):
    digest = "SHA-256=" + base64.b64encode(hashlib.sha256(body).digest()).decode()

    request_target = method.lower() + " " + path
    if raw_query:
        request_target += "?" + raw_query.lstrip("?")
    canonical = "\n".join([
        f"(request-target): {request_target}",
        f"x-timestamp: {timestamp}",
        f"x-nonce: {nonce}",
        f"digest: {digest}",
    ])

    key = serialization.load_pem_private_key(private_key_pem.encode(), password=None)
    der_sig = key.sign(canonical.encode("utf-8"), ec.ECDSA(hashes.SHA256()))
    signature = base64.b64encode(der_sig).decode()

    authorization = (
        f'Signature keyId="{key_id}",alg="ES256",'
        f'headers="(request-target) x-timestamp x-nonce digest",'
        f'signature="{signature}"'
    )
    return digest, authorization

8. Security requirements and key rotation ​

  • Load the private key from environment variables or a key management system; never place it in source code, logs, or public repositories.
  • Use HTTPS; recompute the Digest and signature from the actual request body for every request — never reuse an old signature.
  • Key rotation flow (keyId is the key version):
    1. Generate a new key pair, upload the new public key to Adopay, and register it as a new version (e.g. v2).
    2. Switch signing to the new private key with keyId set to v2; during the coexistence window the old v1 can still be verified.
    3. After a stable period, retire the old version and destroy the old private key securely.
  • If you suspect the private key has leaked, contact Adopay immediately to freeze the affected keyId and enable a new version.