Verify callback signatures
Each callback carries x-ca-signature, a base64 RSA-SHA256 signature (PKCS#1 v1.5) made with the gateway's private key. Verify it with the public key from GET /pubkey/.
What is signed
| Callback method | Signed bytes |
|---|---|
GET | The full request URL as it was sent: scheme, host, path and the original query string in its original order. |
POST (form or JSON) | The exact raw request body, before parsing. |
Warning
Do not re-encode the query string or re-serialize the body. Capture the raw bytes first and parse afterwards.
Warning
Behind a proxy or load balancer, rebuild the URL from your own configured external origin (scheme, host and port if present) plus the original request target. Never take the host from an untrusted Host or X-Forwarded-* header.
Get the public key
curl "https://api.suawave.com/pubkey/"The response contains a PKIX PUBLIC KEY PEM in the pubkey field. Keys are RSA with at least 2048 bits. Fetch it over HTTPS once, store it, and refresh it only when you are told about a key rotation. Do not fetch it on every callback. Reject keys shorter than 2048 bits.
Examples
Each example takes the HTTP method, the request target (path and query, exactly as received), the raw body, the signature header, the public key PEM and your trusted origin, and returns whether the signature is valid. A failed check must answer HTTP 401 and do no work.
Get the request target from your framework's raw request URI (Express req.originalUrl, PHP $_SERVER['REQUEST_URI'], ASP.NET Core GetEncodedPathAndQuery()), and the raw body before any JSON or form parsing (Express express.raw, PHP file_get_contents('php://input')).
Go
The Go example uses only the standard library and is compiled and tested against the gateway's own signer.
package verify
import (
"bytes"
"crypto"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"io"
"net/http"
)
func Callback(r *http.Request, publicPEM []byte, trustedOrigin string) error {
block, _ := pem.Decode(publicPEM)
if block == nil {
return errors.New("invalid public key PEM")
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return err
}
key, ok := parsed.(*rsa.PublicKey)
if !ok || key.N.BitLen() < 2048 {
return errors.New("invalid RSA public key")
}
var signed []byte
switch r.Method {
case http.MethodGet:
signed = []byte(trustedOrigin + r.RequestURI)
case http.MethodPost:
signed, err = io.ReadAll(io.LimitReader(r.Body, 65537))
if err != nil {
return err
}
if len(signed) > 65536 {
return errors.New("callback body too large")
}
r.Body = io.NopCloser(bytes.NewReader(signed))
default:
return errors.New("unsupported callback method")
}
signature, err := base64.StdEncoding.DecodeString(r.Header.Get("x-ca-signature"))
if err != nil {
return err
}
digest := sha256.Sum256(signed)
return rsa.VerifyPKCS1v15(key, crypto.SHA256, digest[:], signature)
}C#
using System;
using System.Security.Cryptography;
using System.Text;
public static class CallbackVerifier
{
public static bool Verify(string method, string requestUri, byte[] rawBody,
string signatureBase64, string publicKeyPem, string trustedOrigin)
{
byte[] signed;
if (method == "GET")
{
signed = Encoding.UTF8.GetBytes(trustedOrigin + requestUri);
}
else if (method == "POST" && rawBody.Length <= 65536)
{
signed = rawBody;
}
else
{
return false;
}
byte[] signature;
try
{
signature = Convert.FromBase64String(signatureBase64);
}
catch (FormatException)
{
return false;
}
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
return rsa.VerifyData(signed, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
}JavaScript (Node.js)
const crypto = require('crypto');
function verifyCallback({ method, requestUri, rawBody, signature }, publicKeyPem, trustedOrigin) {
let signed;
if (method === 'GET') {
signed = Buffer.from(trustedOrigin + requestUri, 'utf8');
} else if (method === 'POST') {
if (!Buffer.isBuffer(rawBody) || rawBody.length > 65536) return false;
signed = rawBody;
} else {
return false;
}
if (!signature || !/^[A-Za-z0-9+/]+={0,2}$/.test(signature)) return false;
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(signed);
return verifier.verify(publicKeyPem, signature, 'base64');
}
module.exports = { verifyCallback };PHP
<?php
function verify_callback(string $method, string $requestUri, string $rawBody, string $signatureBase64, string $publicKeyPem, string $trustedOrigin): bool
{
if ($method === 'GET') {
$signed = $trustedOrigin . $requestUri;
} elseif ($method === 'POST') {
if (strlen($rawBody) > 65536) {
return false;
}
$signed = $rawBody;
} else {
return false;
}
$signature = base64_decode($signatureBase64, true);
if ($signature === false) {
return false;
}
return openssl_verify($signed, $signature, $publicKeyPem, OPENSSL_ALGO_SHA256) === 1;
}After verification
A valid signature proves the request came from the gateway. It does not prove the payment is the one you expect. Also:
- Compare the
nonceand order reference in your callback URL with the stored order. - Check
address_in,coinandvalue_coinagainst what you expect. - Persist the
uuidand ignore repeats. - Answer
200with*ok*within 30 seconds.
