Skip to content

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 ​

HeaderFormatMeaning
X-Sig-Versionliteral string v2Opaque version tag. Future scheme bumps will change this.
X-Timestampunix seconds, ASCII decimalWhen the request was signed. You reject if more than ±60 s from your clock.
X-Nonce32 lowercase hex charsbin2hex(random_bytes(16)). Per-request, unique. You store it ≥180 s and reject duplicates.
X-Signaturelowercase 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
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
  • METHOD — uppercase HTTP method (POST).
  • PATH — the full request path as received, including any prefix your wallet API lives under (no query string, no host). If your wallet base URL is https://api.example.com/v1/wallet, we sign /v1/wallet/opentrade, not /opentrade. The example above uses /opentrade only because its base URL sits at the root. Express: use req.baseUrl + req.path (or req.originalUrl without the query), not req.path inside a mounted router.
  • TIMESTAMP — same value as X-Timestamp.
  • NONCE — same value as X-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 req body via express.raw({type: '*/*'}) before any JSON middleware
  • Python (Flask): request.get_data() (cache=True so subsequent reads work)
  • Go: read req.Body and stash a copy before any decoder consumes it

Replay protection ​

ParameterValueNotes
Timestamp skew±60 sReject if abs(now − X-Timestamp) > 60. NTP-sync both sides.
Nonce TTL≥180 sMust be ≥ 2× skew so replayed timestamps can't survive their nonce expiring.
Nonce bit-width128 bits32 hex chars.
Nonce storeRedis (recommended)Per-partner; isolated per shard if you shard.

Sample verifier (Node.js) ​

The verifier is async (the nonce store is awaited) — your route handler must await it inside a try/catch. If the rejection is not awaited, a failing nonce lookup never answers the request and your gateway turns it into a 503 (seen in the field, 2026-10-02): every v2 call hangs while the version check, which throws synchronously, still answers.

js
const crypto = require('crypto')

async 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 (!Number.isFinite(ts) || 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)
  // PATH = the full request path as received, no query string. Inside a router
  // mounted under a prefix (e.g. /v1/wallet) `req.path` drops that prefix — use
  // baseUrl + path (or originalUrl without the query), which is what we sign.
  const path = (req.baseUrl || '') + req.path
  const bodyHash = crypto.createHash('sha256').update(req.rawBody).digest('hex')
  const canonical = `POST\n${path}\n${ts}\n${nonce}\n${bodyHash}`
  const expected = crypto.createHmac('sha256', secret).update(canonical).digest('hex')
  if (sig?.length !== 64 || !crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))) {
    throw new Error('bad signature')
  }
}

// route handler — always answer, whatever the verifier throws
app.post('/opentrade', express.raw({ type: '*/*' }), async (req, res) => {
  try {
    await verify(req, SECRET)
  } catch (e) {
    return res.json({ status: 'InvalidDigest', code: 'W003', message: e.message })
  }
  // ... your wallet logic
})