Neun PredictionsGetting startedIntegration guideErrorsReference

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)))

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
#InputExpected
V1POST /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=
V2Same as V1 keyed with the complete prefix npwq_sbx_ stripped (wrong). Must fail verification.v1=OGzKCgnkKnc9nhk2h09nPBQ5v+vzS455h/eAgAG+/jI=
V3GET /wallet/v1/health, empty bodysha256 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855; v1=e9b52iBY8ccwnw14LwI81eGmRDJY5/GufxCVGCSPviM=
V4Response 200, Wallet-Timestamp 1793232000050, nonce as above, body in test-vectors.jsonsha256 edda8f81831a1a8d653100bc4d022d3c20583ea40e9ee39da4453b689b677c7d; Wallet-Signature: v1=nuRJR2QNWwPWlgMfX62Hvmw4FVFcDFr+/iLkvvGFKk0=
V5Webhook 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.