Skip to content

API Authentication Guide

To ensure secure communication, all requests to this platform's API must undergo strict authentication. This document details how to generate a request signature and pass verification.

Preparation: Obtain API Credentials

Before you begin, please log in to your merchant dashboard and obtain the following two key credentials from the API settings:

  • API_KEY: Your unique identifier.
  • SECRET_KEY: The private key used for calculating signatures. Please keep it safe and do not disclose it.

Core Authentication Flow

The core of the authentication is that the client uses the SECRET_KEY to generate a signature (signature) for the key information of the request. Then, the API_KEY, signature, and other auxiliary information are sent to the server via HTTP headers. The server will recalculate the signature locally in the exact same way and compare it with the signature sent by the client. If they match, the verification passes.

Building the String to Sign

The first step in the signing process is to combine several parts of the request into a single, standardized string according to the following rules. We call this stringToSign.

The structure of stringToSign is as follows, with each part separated by a newline character \n:

REQUEST_BODY\n
TIMESTAMP\n
NONCE

Detailed rules for each component:

VariableDescriptionRules and Explanations
REQUEST_BODYRequest BodyThe original content of the HTTP request body. Notes: 1. For requests without a body, such as GET, this part is an empty string. 2. For POST and PUT requests, this part is the raw byte stream of the request body. Ensure that you do not modify it in any way (e.g., adding/removing spaces, changing the order of JSON fields).
TIMESTAMPTimestampThe current Unix timestamp (in seconds), represented as a string. The server will reject requests with a time difference of more than ±5 minutes from the server time to prevent replay attacks.
NONCERandom NumberA single-use random string used to prevent replay attacks. Requirements: 1. It is recommended to use a UUID or other high-strength random string generator. 2. For the same API_KEY, the NONCE must be unique within the valid time window defined by TIMESTAMP. The server will reject processing a NONCE that has already been received.

Important

When constructing stringToSign, ensure that all text content is UTF-8 encoded.

Calculating the Signature

  1. Use the HMAC-SHA256 algorithm with your SECRET_KEY as the key to hash the stringToSign constructed in the previous step.
  2. Hex-encode the resulting binary hash value.
  3. Convert the encoded hexadecimal string to lowercase to get the final signature.

Pseudocode Example:

python
signature = hmac_sha256(secret_key, stringToSign).hex().lower()

Sending the API Request

Place the following authentication information in the HTTP request header:

Header FieldValueSource
X-Api-KeyYour API_KEYFrom the merchant dashboard
X-TimestampThe timestamp usedMust be consistent with the TIMESTAMP in stringToSign
X-NonceThe nonce usedMust be consistent with the NONCE in stringToSign
X-SignatureThe calculated signaturesignature

Complete Example

Suppose we have the following credentials and request information:

  • API_KEY: 3AUpfeK573UH5vVe
  • SECRET_KEY: 5ShtY7nXAT8Wm2RBeKLv7iPakVyxjddU
  • Request:
http
POST /openapi/v1/payment HTTP/1.1
Content-Type: application/json

{"order_no":"Pay1754574105","chain_type":"bsc","order_amount":"1","product_name":"Test product name","notify_url":"http://api.example.com/my-notify-url","redirect_url":"","meta":""}

Building stringToSign

  1. REQUEST_BODY: {"order_no":"Pay1754574105","chain_type":"bsc","order_amount":"1","product_name":"Test product name","notify_url":"http://api.example.com/my-notify-url","redirect_url":"","meta":""}
  2. TIMESTAMP: Assume it is 1754574105
  3. NONCE: Assume it is random_nonce_str

Concatenate the above parts with \n to get stringToSign (the \n here is for illustration purposes):

text
{"order_no":"Pay1754574105","chain_type":"bsc","order_amount":"1","product_name":"Test product name","notify_url":"http://api.example.com/my-notify-url","redirect_url":"","meta":""}\n1754574105\nrandom_nonce_str

Calculating signature

Use the HMAC-SHA256 algorithm with 5ShtY7nXAT8Wm2RBeKLv7iPakVyxjddU as the key to calculate the signature for the stringToSign above. The resulting signature is ce4f73fcc17722e053f7315bfa48384bc50e579ec760e71fa91a6f7cf0d24bfa.

Assembling and Sending the Request

bash
POST /openapi/v1/payment HTTP/1.1
Host: api.zaepe.com
Content-Type: application/json
X-Api-Key: 3AUpfeK573UH5vVe
X-Nonce: random_nonce_str
X-Signature: ce4f73fcc17722e053f7315bfa48384bc50e579ec760e71fa91a6f7cf0d24bfa
X-Timestamp: 1754574105

{"order_no":"Pay1754574105","chain_type":"bsc","order_amount":"1","product_name":"Test product name","notify_url":"http://api.example.com/my-notify-url","redirect_url":"","meta":""}

Reference Code

Golang

go
package apisign

import (
  "bytes"
  "crypto/hmac"
  "crypto/sha256"
  "encoding/hex"
  "io"
  "net/http"
  "strconv"
  "strings"
  "time"

  "github.com/cockroachdb/errors"
)

const (
  HeaderAPIKey    = "X-Api-Key"
  HeaderTimestamp = "X-Timestamp"
  HeaderNonce     = "X-Nonce"
  HeaderSignature = "X-Signature"

  // DefaultExpiration defines the default expiration time for a signature.
  DefaultExpiration = 30 * time.Second
)

type SignError struct {
  Field string
  Err   error
}

func (e *SignError) Error() string {
  return e.Field + ": " + e.Err.Error()
}

func Verify(req *http.Request, secret string) error {
  if key := req.Header.Get(HeaderAPIKey); key == "" {
    return &SignError{Field: HeaderAPIKey, Err: errors.New("empty api key")}
  }

  timestamp := req.Header.Get(HeaderTimestamp)
  if timestamp == "" {
    return &SignError{Field: HeaderTimestamp, Err: errors.New("empty timestamp")}
  }
  unix, err := strconv.ParseInt(timestamp, 10, 64)
  if err != nil {
    return &SignError{Field: HeaderTimestamp, Err: errors.New("invalid timestamp")}
  }
  t := time.Unix(unix, 0)
  if time.Since(t).Abs() > DefaultExpiration {
    return &SignError{Field: HeaderTimestamp, Err: errors.New("timestamp expired")}
  }

  nonce := req.Header.Get(HeaderNonce)
  if nonce == "" {
    return &SignError{Field: HeaderNonce, Err: errors.New("empty nonce")}
  }

  signature := strings.ToLower(req.Header.Get(HeaderSignature))
  if signature == "" {
    return &SignError{Field: HeaderSignature, Err: errors.New("empty signature")}
  }

  b, err := io.ReadAll(req.Body)
  if err != nil {
    return &SignError{Field: HeaderSignature, Err: errors.Wrap(err, "fail to read body")}
  }
  req.Body = io.NopCloser(bytes.NewReader(b))

  sign := Sign(secret, b, t, nonce)

  if !hmac.Equal([]byte(sign), []byte(signature)) {
    return &SignError{Field: HeaderSignature, Err: errors.New("invalid signature")}
  }

  return nil
}

// Sign generates a signature for the given request body, timestamp, and nonce.
// The signature is an HMAC-SHA256 hash of the request body, timestamp, and nonce, separated by newlines.
func Sign(secretKey string, requestBody []byte, t time.Time, nonce string) string {
  // For debugging, you can easily print the content to be signed.
  // log.Printf("signing content: %s", string(content))
  content := bytes.Join(
    [][]byte{
      requestBody,
      []byte(strconv.FormatInt(t.Unix(), 10)),
      []byte(nonce),
    },
    []byte("\n"),
  )

  mac := hmac.New(sha256.New, []byte(secretKey))
  mac.Write(content)
  return hex.EncodeToString(mac.Sum(nil))
}

PHP

php
class SignError extends \Exception
{
    /**
     * @var string
     */
    protected $field;

    public function __construct(string $field, string $message = "", int $code = 0, \Throwable $previous = null)
    {
        parent::__construct($message, $code, $previous);
        $this->field = $field;
    }

    public function getField(): string
    {
        return $this->field;
    }

    public function __toString(): string
    {
        return $this->field . ": " . $this->getMessage();
    }
}

class ApiSigner
{

    public static function verify(\Psr\Http\Message\ServerRequestInterface $request, string $secret): bool
    {
        $key = $request->getHeaderLine('X-Api-Key');
        if (empty($key)) {
            throw new SignError("X-Api-Key", "empty api key");
        }

        $timestamp = $request->getHeaderLine('X-Timestamp');
        if (empty($timestamp)) {
            throw new SignError("X-Timestamp", "empty timestamp");
        }
        $unixTimestamp = (int)$timestamp;
        if ((string)$unixTimestamp !== $timestamp) {
             throw new SignError("X-Timestamp", "invalid timestamp");
        }

        $nonce = $request->getHeaderLine('X-Nonce');
        if (empty($nonce)) {
            throw new SignError("X-Nonce", "empty nonce");
        }

        $signature = strtolower($request->getHeaderLine('X-Signature'));
        if (empty($signature)) {
            throw new SignError("X-Signature", "empty signature");
        }

        $body = (string)$request->getBody();

        $request->getBody()->rewind();

        $sign = self::sign(
            $secret,
            $body,
            $unixTimestamp,
            $nonce
        );

        if ($sign !== $signature) {
            throw new SignError("X-Signature", "invalid signature");
        }

        return true;
    }

    public static function sign(
        string $secretKey,
        string $requestBody,
        int $timestamp,
        string $nonce
    ): string {
        $data = implode("\n", [
            $requestBody,
            (string)$timestamp,
            $nonce,
        ]);

        return hash_hmac('sha256', $data, $secretKey);
    }
}

FAQ

Please troubleshoot in the following order:

  1. Is the SECRET_KEY correct?
  2. Is the concatenation order of stringToSign completely correct?
  3. Is the REQUEST_BODY byte-for-byte identical to what is actually sent? (Check for extra spaces or newlines)
  4. Is the TIMESTAMP a Unix timestamp in seconds and within the server's allowed time window?
  5. Are all strings involved in the concatenation UTF-8 encoded?