Signing guide
status: [BT] signer.ts; vectors ship in packages/predictions-server/test-vectors.json and run in that package specs.
Key rule [BT]
Per ADR-064 the HMAC key is the UTF-8 bytes of the full revealed secret, prefix included. Never strip, hex-decode or base64-decode it. ADR-059 s4.3 ("32 random bytes") disagrees; follow ADR-064 (decided 2026-10-30).
Wallet request: Neun signs, the PAM verifies [BT]
canonical = "v1\n" + METHOD + "\n" + PATH + "\n" + Neun-Timestamp + "\n" + Neun-Nonce + "\n" + hex(sha256(raw body))
Neun-Signature = "v1=" + base64_std_padded(HMAC-SHA256(utf8(secret[Neun-Key-Id]), utf8(canonical)))
- PATH exactly as received, base-URL prefix included, no query string. Timestamps in ms.
- Verify in order:
Neun-Wallet-Version=1else 400UNSUPPORTED_VERSION; signature (constant time) else 401INVALID_SIGNATURE; |now - ts| <= 300,000 ms else 401STALE_REQUEST; nonce unseen within 600,000 ms else 401REPLAYED_NONCE.
Wallet response: the PAM signs, Neun verifies [BT]
canonical = "v1\n" + HTTP_STATUS + "\n" + request Neun-Nonce + "\n" + Wallet-Timestamp + "\n" + hex(sha256(raw response body))
Wallet-Signature = "v1=" + base64(HMAC-SHA256(utf8(responseSecret[Wallet-Key-Id]), canonical))
Sign every response, errors included. Neun ignores an unsigned 409.
Webhook: Neun signs, the PAM verifies [FO]
Neun-Signature: t=<unix ms>,v1=<hex>[,v1=<hex>]
v1 = hex(HMAC-SHA256(utf8(npwh secret), utf8(t + "." + raw body)))
Accept if any v1 matches (constant time) and |now - t| <= 300 s. Two v1 values for 7 days during rotation. Do not use the legacy seconds-based format. Bodies are hashed byte for byte; never re-serialise JSON.
Test vectors V1..V5 [BT]
FAKE keys, never valid anywhere:
requestSecret = npwq_sbx_FAKEtestKEYdoNOTuse000000000000000000000000
responseSecret = npws_sbx_FAKEtestKEYdoNOTuse000000000000000000000000
webhookSecret = npwh_sbx_FAKEtestKEYdoNOTuse000000000000000000000000
Neun-Timestamp = 1793232000000 Neun-Nonce = 3f1e2d3c-4b5a-4968-8776-655443322110
| # | Input | Expected |
|---|---|---|
| V1 | POST /wallet/v1/debit, body {"operationId":"7b0c1d4e-0000-5000-8000-000000000001","operationType":"DEBIT","amount":{"amount":"10.00","currency":"EUR"}} | sha256 50946ecc1e0b35ef2aa4418cb935a43abe0aeaaa65d528cbe11c40a4fa069adb; Neun-Signature: v1=Z1C6wo81R/tWDcnWZ10NnqcYcRc8lG4u03YsIsZntyo= |
| V2 | Same as V1 keyed with the complete prefix npwq_sbx_ stripped (wrong). Must fail verification. | v1=OGzKCgnkKnc9nhk2h09nPBQ5v+vzS455h/eAgAG+/jI= |
| V3 | GET /wallet/v1/health, empty body | sha256 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855; v1=e9b52iBY8ccwnw14LwI81eGmRDJY5/GufxCVGCSPviM= |
| V4 | Response 200, Wallet-Timestamp 1793232000050, nonce as above, body in test-vectors.json | sha256 edda8f81831a1a8d653100bc4d022d3c20583ea40e9ee39da4453b689b677c7d; Wallet-Signature: v1=nuRJR2QNWwPWlgMfX62Hvmw4FVFcDFr+/iLkvvGFKk0= |
| V5 | Webhook t=1793232000000, body {"subscriptionId":"sub-1","deliveryId":"del-1","events":[]} | t=1793232000000,v1=d7db318cbc69343343beabbac9fa43fcc7714a1489bdc733d79a592a60ce01f9 |
The published vectors keep the 38-character FAKE tail so they stay byte-identical with the integration guide and the SDK specs. The quickstart key has a 43-character FAKE tail.