Webhook Signature Verification
Before ADOPAY sends a business-result callback to a merchant, the platform signs the HTTP request with its private key. The merchant must verify that signature with the platform public key supplied during onboarding before persisting or processing the business payload.
This page describes the callback-signing protocol used by the current platform service. For event fields, retries, and idempotency, see Webhook Specification. For requests that merchants send to ADOPAY, see Request Signing.
1. End-to-end flow
flowchart LR
A[Business state changes] --> B[Platform creates callback JSON]
B --> C[Compute Digest from raw body]
C --> D[Build four-line canonical string]
D --> E[Create ES256 signature with platform private key]
E --> F[Send webhook with signature headers]
F --> G[Merchant verifies with platform public key]
G --> H[Process the event and return SUCCESS]The platform signs the final URL and body that it sends. The merchant must obtain the same data from the original HTTP request and must not parse and re-serialize the JSON before verification.
2. Callback headers
| Header | Required | Description |
|---|---|---|
X-Merchant-Id | Yes | Merchant number receiving the notification. |
X-Timestamp | Yes | Unix timestamp in seconds when the platform created the signature. |
X-Nonce | Yes | Random value for this delivery, used for replay protection. |
Digest | Yes | Raw-body digest in the format SHA-256=<standard Base64>. |
Authorization | Yes | ES256 signature metadata containing keyId, alg, headers, and signature. |
Authorization.keyId | Yes | Current platform signing-key version; currently v1. |
X-Secret-Key-Version | No | Compatibility key-version header, sent only when a value is available. |
X-Trace-Id | No | Platform trace ID; include it when asking ADOPAY to investigate a delivery. |
The current callback uses this Authorization format:
Signature keyId="<signing-subject-id>",alg="ES256",headers="(request-target) x-timestamp x-nonce digest",signature="<standard-Base64-signature>"The two keyId values have different meanings
The keyId parameter inside Authorization identifies the signing subject. If the current callback flow does not provide it explicitly, it falls back to the merchant number receiving the notification. The separate Authorization.keyId header is the platform signing-key version (currently v1). Select the platform public key from the version information supplied by ADOPAY; do not require these two values to be equal.
3. Verification flow
Process each callback in this order:
- Preserve the original data: before parsing JSON, retain the raw body bytes, escaped request path, and raw query string.
- Check required headers: reject the request if any required header in section 2 is missing.
- Check the timestamp: parse
X-Timestampas Unix seconds. A maximum difference of 300 seconds from the current time is recommended. - Check the Digest: compute
Base64(SHA-256(raw body bytes)), add theSHA-256=prefix, and compare it byte-for-byte withDigest. - Check signature metadata: parse
Authorization;algmust be exactlyES256, andheadersmust be exactly(request-target) x-timestamp x-nonce digest. - Select the platform public key: select the correct environment-specific public key by the platform signing-key version. Sandbox and production keys are independent.
- Rebuild the canonical string: construct the four lines in section 4 in the fixed order, separated only by
\n, with no newline after the final line. - Verify ES256: SHA-256 hash the canonical string, then use the P-256 platform public key to verify the ASN.1/DER ECDSA signature. Decode
signaturewith standard Base64, not Base64URL. - Prevent replay: atomically deduplicate on
X-Merchant-Id + X-Nonce, retaining the key for at least the accepted timestamp window. - Process the event: only after every check succeeds, perform event-level idempotency checks and persist the event.
If any step fails, do not process the business payload and do not downgrade verification to log-only handling.
4. Rebuilding the canonical string
The canonical string always contains these four lines:
(request-target): <lowercase HTTP method> <escaped path>[?<raw query>]
x-timestamp: <original X-Timestamp value>
x-nonce: <original X-Nonce value>
digest: <original Digest value>For example, if the platform calls:
POST https://merchant.example.com/webhooks/payment?source=adopaywith these headers:
X-Timestamp: 1790200000
X-Nonce: 550e8400-e29b-41d4-a716-446655440000
Digest: SHA-256=hxLlwyZ8YdkeKVEmKUMSxnDkPaDnj9LAp8+jwyqwRaI=the canonical string is:
(request-target): post /webhooks/payment?source=adopay
x-timestamp: 1790200000
x-nonce: 550e8400-e29b-41d4-a716-446655440000
digest: SHA-256=hxLlwyZ8YdkeKVEmKUMSxnDkPaDnj9LAp8+jwyqwRaI=Construction rules:
- Do not include the URL scheme, host, or fragment.
- Use the escaped path exactly as received; treat an empty path as
/. - Preserve the raw query ordering and encoding; do not sort or decode and re-encode it.
- Lowercase the HTTP method; current webhooks use
post. - Header names in the canonical string are fixed in lowercase, while their values must remain exactly as received.
5. Digest and signature algorithms
Digest formula:
Digest = "SHA-256=" + Base64Standard(SHA256(rawBodyBytes))Signature verification formula:
signatureDER = Base64StandardDecode(Authorization.signature)
hash = SHA256(UTF8(canonicalString))
valid = ECDSA-P256-VerifyASN1(platformPublicKey, hash, signatureDER)The platform signature is an ASN.1/DER-encoded ECDSA signature, not a fixed 64-byte r || s value.
6. Multi-language verification examples
Each example accepts the raw body bytes, escaped path, and raw query, then verifies the timestamp, Digest, signature metadata, and ES256 signature. platformPublicKeyPEM must be the environment-specific platform public key selected using Authorization.keyId.
After the verification function succeeds, atomically store X-Merchant-Id + X-Nonce with a TTL in Redis or a database. The examples intentionally avoid an in-process Map because it does not provide replay protection across multiple application instances.
// Go 1.20+, standard library only.
package webhook
import (
"crypto/ecdsa"
"crypto/sha256"
"crypto/subtle"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
var authorizationPattern = regexp.MustCompile(
`^Signature keyId="([^"]+)",alg="([^"]+)",headers="([^"]+)",signature="([^"]+)"$`,
)
func VerifyWebhook(method, path, rawQuery string, headers http.Header,
body, platformPublicKeyPEM []byte, now time.Time) error {
merchantID := headers.Get("X-Merchant-Id")
timestamp := headers.Get("X-Timestamp")
nonce := headers.Get("X-Nonce")
digest := headers.Get("Digest")
authorization := headers.Get("Authorization")
keyVersion := headers.Get("Authorization.keyId")
if merchantID == "" || timestamp == "" || nonce == "" || digest == "" ||
authorization == "" || keyVersion == "" {
return fmt.Errorf("missing required signature header")
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return fmt.Errorf("invalid timestamp: %w", err)
}
diff := now.Unix() - ts
if diff < 0 {
diff = -diff
}
if diff > 300 {
return fmt.Errorf("timestamp outside allowed skew")
}
sum := sha256.Sum256(body)
expectedDigest := "SHA-256=" + base64.StdEncoding.EncodeToString(sum[:])
if subtle.ConstantTimeCompare([]byte(expectedDigest), []byte(digest)) != 1 {
return fmt.Errorf("digest mismatch")
}
parts := authorizationPattern.FindStringSubmatch(authorization)
if len(parts) != 5 || parts[2] != "ES256" ||
parts[3] != "(request-target) x-timestamp x-nonce digest" {
return fmt.Errorf("invalid Authorization metadata")
}
signatureDER, err := base64.StdEncoding.DecodeString(parts[4])
if err != nil {
return fmt.Errorf("invalid signature Base64: %w", err)
}
requestTarget := strings.ToLower(method) + " " + path
if rawQuery != "" {
requestTarget += "?" + strings.TrimPrefix(rawQuery, "?")
}
canonical := strings.Join([]string{
"(request-target): " + requestTarget,
"x-timestamp: " + timestamp,
"x-nonce: " + nonce,
"digest: " + digest,
}, "\n")
block, _ := pem.Decode(platformPublicKeyPEM)
if block == nil {
return fmt.Errorf("invalid platform public key PEM")
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return err
}
publicKey, ok := parsed.(*ecdsa.PublicKey)
if !ok || publicKey.Curve.Params().Name != "P-256" {
return fmt.Errorf("platform public key must be P-256")
}
hash := sha256.Sum256([]byte(canonical))
if !ecdsa.VerifyASN1(publicKey, hash[:], signatureDER) {
return fmt.Errorf("signature invalid")
}
// Next: atomically store merchantID + ":" + nonce with a TTL of at least 300 seconds.
return nil
}// Java 8+, standard library only; the platform key is an X.509 PUBLIC KEY PEM.
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.Signature;
import java.security.interfaces.ECPublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class WebhookVerifier {
private static final Pattern AUTH = Pattern.compile(
"^Signature keyId=\"([^\"]+)\",alg=\"([^\"]+)\",headers=\"([^\"]+)\",signature=\"([^\"]+)\"$");
public static void verify(String method, String path, String rawQuery,
Map<String, String> headers, byte[] rawBody,
byte[] platformPublicKeyPem, Instant now) throws Exception {
String merchantId = required(headers, "X-Merchant-Id");
String timestamp = required(headers, "X-Timestamp");
String nonce = required(headers, "X-Nonce");
String digest = required(headers, "Digest");
String authorization = required(headers, "Authorization");
required(headers, "Authorization.keyId");
long timestampSeconds = Long.parseLong(timestamp);
if (Math.abs(now.getEpochSecond() - timestampSeconds) > 300) {
throw new SecurityException("timestamp outside allowed skew");
}
String expectedDigest = "SHA-256=" + Base64.getEncoder().encodeToString(
MessageDigest.getInstance("SHA-256").digest(rawBody));
if (!MessageDigest.isEqual(expectedDigest.getBytes(StandardCharsets.US_ASCII),
digest.getBytes(StandardCharsets.US_ASCII))) {
throw new SecurityException("digest mismatch");
}
Matcher matcher = AUTH.matcher(authorization);
if (!matcher.matches() || !"ES256".equals(matcher.group(2))
|| !"(request-target) x-timestamp x-nonce digest".equals(matcher.group(3))) {
throw new SecurityException("invalid Authorization metadata");
}
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(platformPublicKeyPem, StandardCharsets.US_ASCII)
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");
ECPublicKey publicKey = (ECPublicKey) KeyFactory.getInstance("EC")
.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(pem)));
if (publicKey.getParams().getCurve().getField().getFieldSize() != 256) {
throw new SecurityException("platform public key must be P-256");
}
Signature verifier = Signature.getInstance("SHA256withECDSA");
verifier.initVerify(publicKey);
verifier.update(canonical.getBytes(StandardCharsets.UTF_8));
if (!verifier.verify(Base64.getDecoder().decode(matcher.group(4)))) {
throw new SecurityException("signature invalid");
}
// Next: atomically store merchantId + ":" + nonce with a TTL of at least 300 seconds.
}
private static String required(Map<String, String> headers, String name) {
return headers.entrySet().stream()
.filter(e -> e.getKey().equalsIgnoreCase(name))
.map(Map.Entry::getValue)
.filter(v -> v != null && !v.isEmpty())
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("missing header: " + name));
}
}# Python 3.9+; dependency: pip install cryptography
import base64
import hashlib
import hmac
import re
import time
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
AUTH = re.compile(
r'^Signature keyId="([^"]+)",alg="([^"]+)",'
r'headers="([^"]+)",signature="([^"]+)"$'
)
def verify_webhook(method, path, raw_query, headers, raw_body,
platform_public_key_pem, now=None):
headers = {key.lower(): value.strip() for key, value in headers.items()}
def required(name):
value = headers.get(name.lower(), "")
if not value:
raise ValueError(f"missing header: {name}")
return value
merchant_id = required("X-Merchant-Id")
timestamp = required("X-Timestamp")
nonce = required("X-Nonce")
digest = required("Digest")
authorization = required("Authorization")
required("Authorization.keyId")
now = int(time.time()) if now is None else int(now)
if abs(now - int(timestamp)) > 300:
raise ValueError("timestamp outside allowed skew")
expected_digest = "SHA-256=" + base64.b64encode(
hashlib.sha256(raw_body).digest()
).decode("ascii")
if not hmac.compare_digest(expected_digest, digest):
raise ValueError("digest mismatch")
match = AUTH.fullmatch(authorization)
if not match or match.group(2) != "ES256" or match.group(3) != \
"(request-target) x-timestamp x-nonce digest":
raise ValueError("invalid Authorization metadata")
request_target = f"{method.lower()} {path}"
if raw_query:
request_target += "?" + raw_query.removeprefix("?")
canonical = "\n".join([
f"(request-target): {request_target}",
f"x-timestamp: {timestamp}",
f"x-nonce: {nonce}",
f"digest: {digest}",
])
public_key = serialization.load_pem_public_key(platform_public_key_pem)
if not isinstance(public_key, ec.EllipticCurvePublicKey) or \
not isinstance(public_key.curve, ec.SECP256R1):
raise ValueError("platform public key must be P-256")
try:
public_key.verify(
base64.b64decode(match.group(4), validate=True),
canonical.encode("utf-8"),
ec.ECDSA(hashes.SHA256()),
)
except InvalidSignature as exc:
raise ValueError("signature invalid") from exc
# Next: atomically store merchant_id + ":" + nonce with a TTL of at least 300 seconds.
return merchant_id, nonce// Node.js 18+, built-in node:crypto only.
const {
createHash,
createPublicKey,
timingSafeEqual,
verify
} = require('node:crypto')
const AUTH = /^Signature keyId="([^"]+)",alg="([^"]+)",headers="([^"]+)",signature="([^"]+)"$/
function verifyWebhook({ method, path, rawQuery, headers, rawBody,
platformPublicKeyPem, nowSeconds = Math.floor(Date.now() / 1000) }) {
const normalized = Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), String(value).trim()])
)
const required = (name) => {
const value = normalized[name.toLowerCase()]
if (!value) throw new Error(`missing header: ${name}`)
return value
}
const merchantId = required('X-Merchant-Id')
const timestamp = required('X-Timestamp')
const nonce = required('X-Nonce')
const digest = required('Digest')
const authorization = required('Authorization')
required('Authorization.keyId')
if (!/^\d+$/.test(timestamp) || Math.abs(nowSeconds - Number(timestamp)) > 300) {
throw new Error('timestamp outside allowed skew')
}
const expectedDigest = 'SHA-256=' + createHash('sha256').update(rawBody).digest('base64')
const expectedBytes = Buffer.from(expectedDigest, 'ascii')
const actualBytes = Buffer.from(digest, 'ascii')
if (expectedBytes.length !== actualBytes.length ||
!timingSafeEqual(expectedBytes, actualBytes)) {
throw new Error('digest mismatch')
}
const match = AUTH.exec(authorization)
if (!match || match[2] !== 'ES256' ||
match[3] !== '(request-target) x-timestamp x-nonce digest') {
throw new Error('invalid Authorization metadata')
}
const requestTarget = method.toLowerCase() + ' ' + path +
(rawQuery ? '?' + rawQuery.replace(/^\?/, '') : '')
const canonical = [
`(request-target): ${requestTarget}`,
`x-timestamp: ${timestamp}`,
`x-nonce: ${nonce}`,
`digest: ${digest}`
].join('\n')
const publicKey = createPublicKey(platformPublicKeyPem)
if (publicKey.asymmetricKeyType !== 'ec' ||
publicKey.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
throw new Error('platform public key must be P-256')
}
const signature = Buffer.from(match[4], 'base64')
if (!verify('sha256', Buffer.from(canonical, 'utf8'), publicKey, signature)) {
throw new Error('signature invalid')
}
// Next: atomically store merchantId + ':' + nonce with a TTL of at least 300 seconds.
return { merchantId, nonce }
}At framework boundaries, path must be the undecoded request path and rawQuery must omit the leading ?. For example, Go uses r.URL.EscapedPath() and r.URL.RawQuery; in Node.js, extract the query directly from the original request URL instead of serializing a parsed query object.
7. Response after verification
The current platform considers a delivery successful only when both conditions are met:
- The HTTP status is between
200and299. - After trimming surrounding whitespace, the response body is exactly
SUCCESSorOK(case-sensitive).
Recommended response:
HTTP/1.1 200 OK
Content-Type: text/plain
SUCCESSComplete signature verification, nonce replay protection, event idempotency, and reliable persistence first, then return success immediately. Run longer business operations asynchronously.
8. Common verification failures
- Computing the Digest from parsed and re-serialized JSON, which changes whitespace, field order, or escaping.
- Using a decoded path, or sorting or re-encoding the query string.
- Treating a millisecond timestamp as Unix seconds.
- Adding a trailing newline after the fourth canonical line.
- Decoding
signatureas Base64URL, or treating the DER signature asr || s. - Verifying with the merchant's own public key; callbacks require the ADOPAY platform public key.
- Treating the
keyIdinsideAuthorizationas the same field as the separateAuthorization.keyIdheader. - Allowing a proxy, gateway, or web framework to rewrite the path, query, or body before verification.