Appearance
Message Integrity (HMAC-SHA256)
Message Integrity (HMAC-SHA256)
Every wallet callback TS sends (/getbalance, /opentrade, /closetrade) is signed. You verify the signature; if it fails, you reject the request. The only unsigned call is /authenticate — there the single-use launch token is the credential.
Wire format
POST /opentrade HTTP/1.1
Content-Type: application/json
X-Sig-Version: v2
X-Timestamp: 1715630400
X-Nonce: 3a7c9e1b4f2d8a5e0c1b9d6f3a8e5c2b
X-Signature: 8f3a4d…a91d
{"token":"…","amount":"10","currency":"USD","externalTradeType":"tap","externalTradeId":"12345","data":[…]}The signature lives in headers; the body carries no integrity field.
Headers
| Header | Format | Meaning |
|---|---|---|
X-Sig-Version | literal string v2 | Opaque version tag. Future scheme bumps will change this. |
X-Timestamp | unix seconds, ASCII decimal | When the request was signed. You reject if more than ±60 s from your clock. |
X-Nonce | 32 lowercase hex chars | bin2hex(random_bytes(16)). Per-request, unique. You store it ≥180 s and reject duplicates. |
X-Signature | lowercase hex (64 chars) | hex(HMAC-SHA256(secret, canonical)) |
Canonical string
Five lines, newline-separated:
METHOD\nPATH\nTIMESTAMP\nNONCE\nSHA256_hex(BODY)Concrete for the example above:
POST
/opentrade
1715630400
3a7c9e1b4f2d8a5e0c1b9d6f3a8e5c2b
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855METHOD— uppercase HTTP method (POST).PATH— request path only (no query string, no host).TIMESTAMP— same value asX-Timestamp.NONCE— same value asX-Nonce.SHA256_hex(BODY)— lowercase hex SHA-256 of the raw bytes the partner received.
Critical: hash the raw body bytes
Do NOT decode and re-encode the JSON before hashing. json_encode output differs across runtimes (float precision, unicode escape flags, key order under sorting helpers) — any drift breaks the signature silently.
- PHP:
file_get_contents('php://input') - Node (Express): grab
reqbody viaexpress.raw({type: '*/*'})before any JSON middleware - Python (Flask):
request.get_data()(cache=True so subsequent reads work) - Go: read
req.Bodyand stash a copy before any decoder consumes it
Replay protection
| Parameter | Value | Notes |
|---|---|---|
| Timestamp skew | ±60 s | Reject if abs(now − X-Timestamp) > 60. NTP-sync both sides. |
| Nonce TTL | ≥180 s | Must be ≥ 2× skew so replayed timestamps can't survive their nonce expiring. |
| Nonce bit-width | 128 bits | 32 hex chars. |
| Nonce store | Redis (recommended) | Per-partner; isolated per shard if you shard. |
Sample verifier (Node.js)
js
const crypto = require('crypto')
function verify(req, secret) {
const v = req.headers['x-sig-version']
const ts = parseInt(req.headers['x-timestamp'], 10)
const nonce = req.headers['x-nonce']
const sig = req.headers['x-signature']
if (v !== 'v2') throw new Error('unsupported version')
if (Math.abs(Date.now()/1000 - ts) > 60) throw new Error('skew')
if (!/^[0-9a-f]{32}$/.test(nonce)) throw new Error('bad nonce')
if (await nonceWasSeen(nonce)) throw new Error('replay')
await rememberNonce(nonce, 180)
const bodyHash = crypto.createHash('sha256').update(req.rawBody).digest('hex')
const canonical = `POST\n${req.path}\n${ts}\n${nonce}\n${bodyHash}`
const expected = crypto.createHmac('sha256', secret).update(canonical).digest('hex')
if (!crypto.timingSafeEqual(Buffer.from(sig,'hex'), Buffer.from(expected,'hex'))) {
throw new Error('bad signature')
}
}