Guide · Developers

How to sign crypto payment API requests (HMAC-SHA256)

Every server-to-server call carries an HMAC over the timestamp, method, path and body. Get one byte wrong and you get a `401`. This guide gives the exact recipe, code in four languages and test vectors to prove your implementation before it touches the network.

Updated · 2 min read

Build it with an AI agent

Give this to your AI agent

Paste it into Claude Code, Cursor or any agent that can read a URL. It reads the OpenAPI contract and the signing recipe, and follows the rules for handling real funds.

Read https://www.payaries.com/ai-agents.md and integrate Payaries crypto payments into this project. Follow its rules for handling real funds.
OpenAPI specDocs as markdown

Never paste your API secret into an agent. Keep it in an environment variable.

01The recipe

Headers and message
X-Api-Key:   <your api key>
X-Timestamp: <current unix time in milliseconds>
X-Signature: hex( HMAC-SHA256( apiSecret,
               timestamp + "\n" + METHOD + "\n" + requestTarget + "\n" + body ) )
  • METHOD is upper-case.
  • requestTarget is the path plus the query string, exactly as you send it, such as /v1/invoices?status=settled&limit=10. If a proxy rewrites paths, sign the path the gateway receives.
  • body is the JSON you send, compact (JSON.stringify output). A request with no body, including every GET, signs the two characters {}.
  • The digest is lower-case hex.
  • The secret is your API secret. The webhook secret is a different value that verifies Payaries's calls to you.

02Test vectors

Reproduce both signatures below before you make a real call. They use a made-up secret and a fixed timestamp.

InputValue
API secretpsk_example_secret_not_real
X-Timestamp1725700000000
Vector 1POST /v1/invoices
Vector 1 body{"referenceId":"order_1","fiat":{"amount":"49.99","currency":"USD"},"crypto":{"asset":"USDT_TRC20","chainId":"tron-mainnet"}}
Vector 1 X-Signature73fac04526b513fd06ff75f45cbe1a944541bd70f372805d9d11e6cca59f32a6
Vector 2GET /v1/invoices?status=settled&limit=10 (body {})
Vector 2 X-Signature70ab5601cea9b9929f8f2a1db0e2c07ea4dd67cf065d076ea4423565b5a8924c

03Node.js

Node 18+
const crypto = require('crypto');

function sign(apiSecret, method, requestTarget, body) {
  const timestamp = Date.now().toString();                      // unix milliseconds
  const payload = JSON.stringify(body ?? {});                   // compact; send exactly this string
  const message = timestamp + '\n' + method.toUpperCase() + '\n' + requestTarget + '\n' + payload;
  const signature = crypto.createHmac('sha256', apiSecret).update(message).digest('hex');
  return { timestamp, signature, payload };
}

async function call(method, requestTarget, body) {
  const { timestamp, signature, payload } = sign(process.env.PAYARIES_API_SECRET, method, requestTarget, body);
  const res = await fetch('https://bqpay-api-gateway.buildquick.tech' + requestTarget, {
    method,
    headers: {
      'Content-Type': 'application/json',
      'X-Api-Key': process.env.PAYARIES_API_KEY,
      'X-Timestamp': timestamp,
      'X-Signature': signature,
    },
    body: method === 'GET' ? undefined : payload,
  });
  return { status: res.status, body: await res.json() };
}

04Python

requests
import hashlib, hmac, json, os, time
import requests

def call(method, request_target, body=None):
    timestamp = str(int(time.time() * 1000))                    # unix milliseconds
    # Compact separators and ensure_ascii=False give the same bytes JSON.stringify would.
    payload = json.dumps(body if body is not None else {}, separators=(",", ":"), ensure_ascii=False)
    message = f"{timestamp}\n{method.upper()}\n{request_target}\n{payload}"
    signature = hmac.new(os.environ["PAYARIES_API_SECRET"].encode(), message.encode(), hashlib.sha256).hexdigest()
    return requests.request(
        method,
        "https://bqpay-api-gateway.buildquick.tech" + request_target,
        headers={
            "Content-Type": "application/json",
            "X-Api-Key": os.environ["PAYARIES_API_KEY"],
            "X-Timestamp": timestamp,
            "X-Signature": signature,
        },
        data=None if method == "GET" else payload.encode(),     # send the exact bytes you signed
        timeout=30,
    )

ensure_ascii=False matters: Python escapes non-ASCII characters by default and JavaScript doesn't, so a body with an accented customer name would otherwise be signed differently from how the gateway re-serialises it.

05Go

Go
package payaries

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strconv"
	"time"
)

// Sign returns the X-Timestamp and X-Signature header values. body must be the exact JSON string you
// will send ("{}" for a request with no body). Marshal the request once and reuse those bytes.
func Sign(apiSecret, method, requestTarget, body string) (timestamp, signature string) {
	timestamp = strconv.FormatInt(time.Now().UnixMilli(), 10)
	mac := hmac.New(sha256.New, []byte(apiSecret))
	mac.Write([]byte(timestamp + "\n" + method + "\n" + requestTarget + "\n" + body))
	return timestamp, hex.EncodeToString(mac.Sum(nil))
}

Marshal the request once and reuse those bytes for both the signature and the body. Go's encoding/json sorts the keys of a map, so build the body from a struct, or sign the same string you send.

06curl and openssl

bash
API_KEY="$PAYARIES_API_KEY"; API_SECRET="$PAYARIES_API_SECRET"
BASE=https://bqpay-api-gateway.buildquick.tech
TS=$(($(date +%s) * 1000))                       # unix milliseconds
TARGET='/v1/invoices?status=settled&limit=10'    # path + query, exactly as sent
BODY='{}'                                        # a GET signs the empty object
SIG=$(printf '%s\n%s\n%s\n%s' "$TS" GET "$TARGET" "$BODY" | openssl dgst -sha256 -hmac "$API_SECRET" -hex | sed 's/^.* //')

curl -s "$BASE$TARGET" \
  -H "X-Api-Key: $API_KEY" -H "X-Timestamp: $TS" -H "X-Signature: $SIG"

07Rules that cause most failures

  • Sign the bytes you send. Build the body string once, hash it, and send that same string.
  • One signature, one request. For POST, PATCH and DELETE a signature works once. Re-sending it within its window is 401 Request signature has already been used. Sign every retry afresh. Reads may repeat.
  • Keep the clock honest. A timestamp more than five minutes off is rejected.
  • Amounts are strings. Send "49.99", not 49.99, so no float formatting can differ between systems.
  • Don't retry a failed signature in a loop. Thirty failed authentications from one IP in ten minutes lock that IP out for ten minutes (429).
  • Never sign in the browser. The secret would ship to every visitor.

The Node SDK signs for you. If you build an HTTP client in another language, write a unit test that reproduces the vectors above.

Questions

What does a Payaries API signature cover?
The timestamp in milliseconds, the upper-case HTTP method, the request path plus query string exactly as sent, and the compact JSON body (the two characters {} when there is no body), joined by newlines and signed with HMAC-SHA256 using the API secret, as lower-case hex.
Why do I get 'Request signature has already been used'?
Each signature on a POST, PATCH or DELETE works once. A retry, including an automatic retry by your HTTP client that replays the same headers, must be signed afresh with a new timestamp.
What do I sign for a GET request?
The same message with the two characters {} as the body, and the query string included in the request target.

Next

Create a test key and run a real invoice

Test keys work immediately, with no approval step. Payaries runs on real mainnet networks, so use a small real payment to test.