HTTP Request Signing

1. What is HTTP Request Signing?

HTTP Request Signing is an enhanced authentication method that uses asymmetric cryptography (public/private key pairs). Instead of relying solely on a shared API key, you sign each request with your private key, and StraitsX verifies it using your public key. This ensures both the integrity and authenticity of every API request.

2. Do I have to switch to this new authentication method?

No. This is an optional upgrade. Your existing API key authentication (X-XFERS-APP-API-KEY) continues to work as before. You can adopt HTTP Request Signing at your own pace.

3. How do I know if HTTP Request Signing is enabled for my account?

To check your status, log in to your StraitsX Dashboard and navigate to the Public Key management section. If the activation toggle is switched On, HTTP Request Signing is active.

📘

Note:

The toggle can only be enabled if you have at least one active key in your account.

4. Which signing algorithm does StraitsX support?

We are using Ed25519 signing algorithm. Ed25519 keys use asymmetric cryptography (no shared secret), making them more suitable for API integrations where the private key never leaves your server.

Ed25519 also generates deterministic signatures (same output for the same input every time), eliminating the need for a secure random number generator during signing and reducing the risk of implementation errors.

5. How do I generate my key pair?

Method 1: Using OpenSSL (Recommended)

Most Linux, macOS, and Windows (via Git Bash) systems have OpenSSL installed.

  1. Generate the Private Key

    Run the following command to create a private key file named private.pem:

    openssl genpkey -algorithm ed25519 -out private.pem
  2. Extract the Public Key

    Now, generate the corresponding public key from that private key:

    openssl pkey -in private.pem -pubout -out public.pem
Method 2: Using ssh-keygen

If you prefer using standard SSH tools, you can generate the key pair this way:

  1. Generate the Key Pair

    ssh-keygen -t ed25519 -N "" -f ./my_signing_key

    This creates my_signing_key (Private) and my_signing_key.pub (Public).

  2. Convert Private Key to PEM Format (if needed)

    Some libraries require PEM format. To convert the SSH private key:

    ssh-keygen -p -m PEM -f ./my_signing_key

Note: You may need to convert keys to PEM format depending on your integration library.

6. How do I upload my public key?

Log in to the StraitsX Dashboard and navigate to the Public Key management section. From there you can upload, view, and manage your public keys.

7. What is the canonical string and how do I build it?

The canonical string is a deterministic representation of your request that both you and StraitsX build independently. It is what gets signed with your Ed25519 private key, and what the server reconstructs to verify your signature.

The signing string is six components joined by newline characters (\n):

METHOD\nPATH\nQUERY\nTIMESTAMP\nNONCE\nBODY
ComponentDescriptionExample
METHODHTTP method, uppercasedPOST, GET, DELETE
PATHRequest path only (no query string, no host)/v1/fx/payouts
QUERYNormalized query string (see below). Empty string if none.page[number]=2&page[size]=20
TIMESTAMPUnix timestamp (seconds since epoch), sent in X-TIMESTAMP header1640000000
NONCEUnique random string per request, sent in X-NONCE headerf47ac10b-58cc-4372-a567-0e02b2c3d479
BODYRaw request body. Empty string for requests without a body (e.g. GET, DELETE){"amount":"100"}
Query String Normalization

Query parameters must be normalized before inclusion in the signing string to ensure both client and server produce an identical canonical string.

Steps:

  1. Take the raw query string from the URL (everything after ?).
  2. Split on & to get individual key=value pairs.
  3. Sort the pairs alphabetically (lexicographic byte order on the full key=value string).
  4. Rejoin with &.
  5. If there are no query parameters, use an empty string "".

Do not decode or re-encode the pairs — sort them exactly as they appear in the URL.

POST Request with JSON Body

Request: POST /v1/fx/payouts

===START OF CANONICAL STRING===
POST
/v1/fx/payouts

1640000000
f47ac10b-58cc-4372-a567-0e02b2c3d479
{"quoteId":"c4d1da72-111e-4d52-bdbf-2e74a2d803d5"}
===END OF CANONICAL STRING===

The QUERY line is an empty string (no query parameters).

Signing the String

Once you’ve built the canonical string:

  1. Sign it using your Ed25519 private key.
  2. Base64-encode the resulting signature (strict encoding, no line breaks).
  3. Send the encoded signature in the X-SIGNATURE header.
Required Headers
HeaderValue
X-XFERS-APP-API-KEYYour API key
X-PUBLIC-KEY-IDID of the registered public key
X-TIMESTAMPSame timestamp used in the signing string
X-NONCESame nonce used in the signing string
X-SIGNATUREBase64-encoded Ed25519 signature

Code Examples:

import time
import uuid
import base64
from urllib.parse import urlparse, parse_qsl, urlencode
from nacl.signing import SigningKey

# Request components
method = "GET"
url = "/v1/fx/payouts?sort=createdAt&page[size]=20"
timestamp = str(int(time.time()))
nonce = str(uuid.uuid4())
body = ""

# Split path and query, normalize query
parsed = urlparse(url)
path = parsed.path
query = "&".join(sorted(parsed.query.split("&"))) if parsed.query else ""

# Build canonical string
signing_string = f"{method}\n{path}\n{query}\n{timestamp}\n{nonce}\n{body}"

# Sign with Ed25519
signing_key = SigningKey(your_private_key_bytes)
signature = signing_key.sign(signing_string.encode()).signature
encoded_signature = base64.b64encode(signature).decode()
import { sign } from 'tweetnacl';
import { Buffer } from 'buffer';
import { v4 as uuidv4 } from 'uuid';

// Request components
const method = 'GET';
const url = '/v1/fx/payouts?sort=createdAt&page[size]=20';
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = uuidv4();
const body = '';

// Split path and query, normalize query
const [path, rawQuery] = url.split('?');
const query = rawQuery ? rawQuery.split('&').sort().join('&') : '';

// Build canonical string
const signingString = [method, path, query, timestamp, nonce, body].join('\n');

// Sign with Ed25519
const signature = sign.detached(
  Buffer.from(signingString),
  yourPrivateKeyBytes
);
const encodedSignature = Buffer.from(signature).toString('base64');
package main

import (
	"crypto/ed25519"
	"crypto/x509"
	"encoding/base64"
	"encoding/pem"
	"fmt"
	"net/url"
	"os"
	"sort"
	"strings"
	"time"

	"github.com/google/uuid"
)

func main() {

	// Request components
	method := "GET"
	rawURL := "/v1/fx/payouts?sort=createdAt&page[size]=20"
	timestamp := fmt.Sprintf("%d", time.Now().Unix())
	nonce := uuid.New().String()
	body := ""

	// Split path and query, normalize query
	parsed, _ := url.Parse(rawURL)
	path := parsed.Path
	query := ""
	if parsed.RawQuery != "" {
		pairs := strings.Split(parsed.RawQuery, "&")
		sort.Strings(pairs)
		query = strings.Join(pairs, "&")
	}

	// Build canonical string
	signingString := strings.Join([]string{method, path, query, timestamp, nonce, body}, "\n")
  
	// Sign with Ed25519
	keyData, _ := os.ReadFile("private.pem")
	block, _ := pem.Decode(keyData)
	parsedKey, _ := x509.ParsePKCS8PrivateKey(block.Bytes)
	privateKey := parsedKey.(ed25519.PrivateKey)
	signatureBytes := ed25519.Sign(privateKey, []byte(signingString))
	encodedSignature := base64.StdEncoding.EncodeToString(signatureBytes)

	fmt.Println("Signature:", encodedSignature)
}
require "openssl"
require "base64"
require "securerandom"
require "uri"

# Request components
method = "GET"
raw_url = "/v1/fx/payouts?sort=createdAt&page[size]=20"
timestamp = Time.now.to_i.to_s
nonce = SecureRandom.uuid
body = ""

# Split path and query, normalize query
uri = URI.parse(raw_url)
path = uri.path
query = uri.query ? uri.query.split("&").sort.join("&") : ""

# Build canonical string
signing_string = [method, path, query, timestamp, nonce, body].join("\n")

# Sign with Ed25519
private_key = OpenSSL::PKey.read(File.read("private.pem"))
signature_bytes = private_key.sign(nil, signing_string)
encoded_signature = Base64.strict_encode64(signature_bytes)

puts "Signature: #{encoded_signature}"
📘

Important Notes:

  • Sort the raw URL-encoded pairs. If your URL contains filter%5BpageSize%5D=20, sort using that encoded form, not the decoded filter[pageSize]=20.
  • The PATH component never includes the query string. /v1/fx/transactions only — no ?.
  • The QUERY and BODY components are separate. GET requests always have an empty BODY, regardless of query parameters.
  • Duplicate keys are allowed. If the URL has tag=a&tag=b, both pairs are kept and sorted independently.
  • The timestamp and nonce in the headers must match exactly what was used in the signing string. Any mismatch will cause verification to fail.
  • Each nonce must be unique — reusing a nonce will be rejected as a replay attack.
  • The timestamp must be recent — expired timestamps are rejected.
  • The body must be the exact raw string sent in the request. Differences in whitespace or key ordering will invalidate the signature.

8. What is the timestamp tolerance?

The server accepts requests with timestamps within ±300 seconds (5 minutes) of the server’s current time. Requests outside this window will be rejected with a clock_skew error. Ensure your server clock is synchronized via NTP.

9. What is a nonce and why is it needed?

A nonce ("number used once") is a unique UUID string included in each request. It prevents replay attacks — if someone intercepts a valid signed request and tries to resend it, the server will detect the duplicate nonce and reject it.

The accepted regex format for this UUID is /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i.

10. What happens if my signature is invalid?

The API will return one of the following error responses:

ErrorError CodeHTTP StatusDescription
Missing Required Signature HeadersSTXE-3000400One or more required signing headers (X-XFERS-APP-API-KEY, X-PUBLIC-KEY-ID, X-SIGNATURE, X-TIMESTAMP, X-NONCE) are missing.
Missing Public Key IDSTXE-3000400The X-PUBLIC-KEY-ID header is required when signature authentication is enabled for your account.
Invalid Nonce FormatSTXE-3000400The nonce must be a valid UUID format.
Invalid Timestamp FormatSTXE-3000400The provided timestamp format is invalid. Please use Unix timestamp (seconds since epoch).
Public Key InactiveSTXE-4000400The specified public key is inactive and cannot be used for signature verification.
Invalid Request SignatureSTXE-1000401The provided signature could not be verified. Ensure you are signing the canonical string correctly with your private key.
Request Timestamp ExpiredSTXE-1000401The request timestamp is outside the acceptable time window (±5 minutes). Ensure your system clock is synchronized.
Replay Attack DetectedSTXE-1000401This nonce has already been used. Generate a new unique nonce for each request.
Key Ownership MismatchSTXE-2000403The API token and public key must belong to the same user.
Public Key Not FoundSTXE-5000404The specified public key could not be found or has been deactivated.

11. Can I rotate my keys?

Yes. The system supports multiple public keys identified by X-PUBLIC-KEY-ID. You can upload a new public key, start using it in your requests, and then deactivate or delete the old key — enabling zero-downtime key rotation.

12. Can I switch back to API key authentication?

Yes. HTTP Request Signing is controlled by a per-account configuration. If needed, it can be toggled from the StraitsX Account Dashboard and your existing API key authentication will continue to work as before.