# 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 2026-10-10 · Payaries · https://www.payaries.com/guides/sign-crypto-payment-api-requests

## The 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.

## Test vectors

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

| Input | Value |
|---|---|
| API secret | `psk_example_secret_not_real` |
| `X-Timestamp` | `1725700000000` |
| Vector 1 | `POST` `/v1/invoices` |
| Vector 1 body | `{"referenceId":"order_1","fiat":{"amount":"49.99","currency":"USD"},"crypto":{"asset":"USDT_TRC20","chainId":"tron-mainnet"}}` |
| Vector 1 `X-Signature` | `73fac04526b513fd06ff75f45cbe1a944541bd70f372805d9d11e6cca59f32a6` |
| Vector 2 | `GET` `/v1/invoices?status=settled&limit=10` (body `{}`) |
| Vector 2 `X-Signature` | `70ab5601cea9b9929f8f2a1db0e2c07ea4dd67cf065d076ea4423565b5a8924c` |

> **If your signature differs.** Print the exact string you are hashing. The usual culprits are a space after a colon or comma in the body, a different key order, the query string left out of the target, a timestamp in seconds, and a missing `{}` on a GET.

## Node.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() };
}
```

## Python

**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.

## Go

**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.

## curl 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"
```

## Rules 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.

## Try it

Create a test key and run a real invoice: [Payaries — www.payaries.com](https://www.payaries.com/signup). Payaries is a multi-chain crypto payment gateway — https://www.payaries.com

## Keep reading

- https://www.payaries.com/crypto-payment-api — A crypto payment API that behaves like the rest of your stack
- https://www.payaries.com/guides/crypto-payment-api-quickstart — Crypto payment API quickstart: from signup to your first payout
- https://www.payaries.com/guides/verify-crypto-webhook-signatures — How to verify crypto payment webhook signatures
- https://www.payaries.com/guides/crypto-payment-api-errors-and-retries — Crypto payment API errors, rate limits and safe retries
- https://www.payaries.com/ai-agents — Build a crypto payment integration with an AI agent, safely
- https://www.payaries.com/guides/crypto-withdrawals-api — Withdrawing crypto through the API: quote, idempotency and approval
- https://www.payaries.com/blog/big-tech-ai-regulation-demands — What Big Tech actually wants from AI regulation: one rulebook, not none
