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:
| Header | Description |
|---|---|
X-Merchant-Id | Merchant number (top-level merchant number). The server uses it to locate the merchant's verification public key configuration. |
X-Timestamp | Unix timestamp in seconds, digits only (do not send milliseconds). The server checks the offset from the current time, ±300 seconds by default. |
X-Nonce | Anti-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). |
Digest | Request 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=. |
Authorization | ES256 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
| Item | Requirement |
|---|---|
| Algorithm | ECDSA P-256 (ES256) |
| Private key | Generated and kept by the integrator; SEC1 (EC PRIVATE KEY) or PKCS8 (PRIVATE KEY) PEM supported |
| Public key | Uploaded to Adopay for verification; PUBLIC KEY PEM |
| Key version | Identified 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:
(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:
- 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. - The values of the
x-timestampandx-noncelines are exactly theX-TimestampandX-Nonceheader values. - The
digestline carries the fullSHA-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:
(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):
(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:
Signature keyId="<key version>",alg="ES256",headers="(request-target) x-timestamp x-nonce digest",signature="<Base64 signature>"keyId: the key version registered with Adopay,v1by default; the server locates the exact public key byX-Merchant-Id+keyId.algmust be exactlyES256andheadersmust 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
| Header | Description |
|---|---|
X-Merchant-Id | The platform signing identity (not the merchant number here). |
X-Timestamp / X-Nonce | Timestamp and nonce for this response (not the request's original values). |
Digest | Response body digest, computed over the raw response body bytes. |
Authorization | Platform ES256 signature. |
Authorization.keyId | Platform 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:
- The
(request-target)line reuses this request's lowercase method, path, and raw query (it is not the response status line). - The
x-timestamp,x-nonce, anddigestlines take the response header values, withdigestcomputed over the raw response body bytes.
Verification flow
- Recompute SHA-256 over the response body raw bytes and compare it verbatim with the response
Digestheader; do not deserialize the JSON and re-serialize it first. - Check that
X-Timestampis within the allowed time window and thatX-Noncehas not appeared before (anti-replay). - Parse the
signaturein the responseAuthorization, locate the platform public key bykeyId, 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):
- Required headers: all five headers —
X-Merchant-Id,X-Timestamp,X-Nonce,Digest,Authorization— must be present. - Nonce anti-replay: a given merchant number +
X-Noncepair is accepted only the first time it appears within the validity window (300 seconds by default); repeats are rejected outright. - Timestamp check:
X-Timestampmust be a valid Unix second-level timestamp within ±300 seconds of the current time. - Digest check: the server reads the raw request body bytes, recomputes SHA-256, and compares it verbatim with the
Digestheader. - Authorization parsing and algorithm check: parses the
keyId/alg/headers/signatureparameters;algmust beES256andheadersmust be exactly(request-target) x-timestamp x-nonce digest. - Public key lookup: finds the merchant public key registered with Adopay by
X-Merchant-Id+keyId. - 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 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 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);
}
}// 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 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 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, authorization8. 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 (
keyIdis the key version):- Generate a new key pair, upload the new public key to Adopay, and register it as a new version (e.g.
v2). - Switch signing to the new private key with
keyIdset tov2; during the coexistence window the oldv1can still be verified. - After a stable period, retire the old version and destroy the old private key securely.
- Generate a new key pair, upload the new public key to Adopay, and register it as a new version (e.g.
- If you suspect the private key has leaked, contact Adopay immediately to freeze the affected
keyIdand enable a new version.