Skip to content
For LLMsView as Markdown·

For an engineer porting @onehazel/connect-adapter to another language, or checking their own implementation of the envelope.

Envelope spec and test vectors ​

This is what an implementation in front of a PAM must do with each request OneHazel sends. The Node adapter is the reference; the contract is the authority on routes and bodies. Every check below runs on the RAW request bytes, before the body is parsed.

Wallet and player calls ​

Applies to every wallet route (session/start, session/exchange, session/balance, wallet/bet, wallet/win, wallet/rollback, freerounds/issue, freerounds/cancel, freerounds/settle, freerounds/get) and to player/lookup. All are POST.

  1. Bearer. Authorization must be exactly Bearer <callback key>. Compare in constant time. Failure: 401, code unauthorized.
  2. Signature. X-OneHazel-Signature is a comma-separated list of key=value parts. Read t (unix seconds, digits only) and every v1 (exactly 64 lowercase hex characters); ignore other parts. Reject when t is missing, when abs(now - t) > 300, or when no v1 is present. The expected value is lowercase hex of HMAC-SHA256(key = secret, message = "<t>." + raw body), where the secret is the whole whsec_... string as UTF-8 bytes. Accept when ANY v1 equals the expected value for ANY configured secret (two secrets only during a rotation hour). Compare every candidate in constant time, with no early exit. Failure: 401, code signature_invalid.
  3. Environment. X-OneHazel-Env must be exactly test or live. Anything else: 400, code invalid_request.
  4. Test players. When the body names a player (session/start, freerounds/issue, player/lookup), a test call must name a configured test player and a live call must not. Failure: 404, code player_unknown.
  5. Forward. Send the raw body bytes unchanged to the PAM, with only Content-Type, Accept, Idempotency-Key and the PAM's own auth header. Drop Authorization and every X-OneHazel-* header. Never follow a redirect.

Webhook deliveries ​

webhook.receive uses the webhook engine's format, not the one above. There is no bearer, no timestamp and no X-OneHazel-Env.

  1. Signature. After trimming surrounding whitespace, X-OneHazel-Signature must be exactly sha256= followed by 64 lowercase hex characters. The expected value is lowercase hex of HMAC-SHA256(key = secret, message = raw body). Accept when it matches ANY configured secret, compared in constant time. Failure: 401, code signature_invalid.
  2. Dedup. With no timestamp, a replayed delivery verifies, so deduplicate on deliveryId before forwarding. Release the claim when forwarding fails so the retry is not swallowed.
  3. Answer within 5 s. 200 once forwarded; 503 when the sink fails, so OneHazel retries.

Errors ​

Every rejection and every mapped PAM error is the flat envelope and nothing else:

json
{ "code": "signature_invalid", "message": "signature rejected", "retryable": false }

retryable is true only for internal_error and unavailable. Messages are written by the implementation; a PAM's error body is never echoed.

Test vectors ​

Each vector is one signature check. kind: "call" uses the wallet and player rule with now as the current time and a 300 s tolerance; kind: "webhook" uses the webhook rule. secrets is the configured list, header the X-OneHazel-Signature value, and body the exact raw bytes as a UTF-8 string. An implementation passes when its verdict equals expect for every vector. The secrets are fixed test values, never real ones.

json
[
  {
    "id": "call-valid",
    "kind": "call",
    "body": "{\"session_token\":\"sess_test_1\",\"round_id\":\"r-1\",\"amount_minor\":250,\"currency\":\"EUR\",\"idempotency_key\":\"ik_vec_1\"}",
    "secrets": [
      "whsec_testvector_current"
    ],
    "header": "t=1760000000,v1=d8411bce6961d2f77b348f346926de0bfe2d80a65dc0a862b75c0acfabf6a0d7",
    "now": 1760000010,
    "expect": "accept"
  },
  {
    "id": "call-future-within-tolerance",
    "kind": "call",
    "body": "{\"session_token\":\"sess_test_1\",\"round_id\":\"r-1\",\"amount_minor\":250,\"currency\":\"EUR\",\"idempotency_key\":\"ik_vec_1\"}",
    "secrets": [
      "whsec_testvector_current"
    ],
    "header": "t=1760000000,v1=d8411bce6961d2f77b348f346926de0bfe2d80a65dc0a862b75c0acfabf6a0d7",
    "now": 1759999700,
    "expect": "accept"
  },
  {
    "id": "call-stale",
    "kind": "call",
    "body": "{\"session_token\":\"sess_test_1\",\"round_id\":\"r-1\",\"amount_minor\":250,\"currency\":\"EUR\",\"idempotency_key\":\"ik_vec_1\"}",
    "secrets": [
      "whsec_testvector_current"
    ],
    "header": "t=1760000000,v1=d8411bce6961d2f77b348f346926de0bfe2d80a65dc0a862b75c0acfabf6a0d7",
    "now": 1760000301,
    "expect": "reject"
  },
  {
    "id": "call-body-changed",
    "kind": "call",
    "body": "{\"session_token\":\"sess_test_1\",\"round_id\":\"r-1\",\"amount_minor\":251,\"currency\":\"EUR\",\"idempotency_key\":\"ik_vec_1\"}",
    "secrets": [
      "whsec_testvector_current"
    ],
    "header": "t=1760000000,v1=d8411bce6961d2f77b348f346926de0bfe2d80a65dc0a862b75c0acfabf6a0d7",
    "now": 1760000010,
    "expect": "reject"
  },
  {
    "id": "call-reserialised-body",
    "kind": "call",
    "body": "{\n \"session_token\": \"sess_test_1\",\n \"round_id\": \"r-1\",\n \"amount_minor\": 250,\n \"currency\": \"EUR\",\n \"idempotency_key\": \"ik_vec_1\"\n}",
    "secrets": [
      "whsec_testvector_current"
    ],
    "header": "t=1760000000,v1=d8411bce6961d2f77b348f346926de0bfe2d80a65dc0a862b75c0acfabf6a0d7",
    "now": 1760000010,
    "expect": "reject"
  },
  {
    "id": "call-wrong-secret",
    "kind": "call",
    "body": "{\"session_token\":\"sess_test_1\",\"round_id\":\"r-1\",\"amount_minor\":250,\"currency\":\"EUR\",\"idempotency_key\":\"ik_vec_1\"}",
    "secrets": [
      "whsec_testvector_previous"
    ],
    "header": "t=1760000000,v1=d8411bce6961d2f77b348f346926de0bfe2d80a65dc0a862b75c0acfabf6a0d7",
    "now": 1760000010,
    "expect": "reject"
  },
  {
    "id": "call-rotation-previous-secret",
    "kind": "call",
    "body": "{\"session_token\":\"sess_test_1\",\"round_id\":\"r-1\",\"amount_minor\":250,\"currency\":\"EUR\",\"idempotency_key\":\"ik_vec_1\"}",
    "secrets": [
      "whsec_testvector_current",
      "whsec_testvector_previous"
    ],
    "header": "t=1760000000,v1=973285fb0841d56ee493e3c53a78cafdf00bbc42ba50a3775ac870485926788b",
    "now": 1760000010,
    "expect": "accept"
  },
  {
    "id": "call-uppercase-hex",
    "kind": "call",
    "body": "{\"session_token\":\"sess_test_1\",\"round_id\":\"r-1\",\"amount_minor\":250,\"currency\":\"EUR\",\"idempotency_key\":\"ik_vec_1\"}",
    "secrets": [
      "whsec_testvector_current"
    ],
    "header": "t=1760000000,v1=D8411BCE6961D2F77B348F346926DE0BFE2D80A65DC0A862B75C0ACFABF6A0D7",
    "now": 1760000010,
    "expect": "reject"
  },
  {
    "id": "call-two-v1-one-valid",
    "kind": "call",
    "body": "{\"session_token\":\"sess_test_1\",\"round_id\":\"r-1\",\"amount_minor\":250,\"currency\":\"EUR\",\"idempotency_key\":\"ik_vec_1\"}",
    "secrets": [
      "whsec_testvector_current"
    ],
    "header": "t=1760000000,v1=0000000000000000000000000000000000000000000000000000000000000000,v1=d8411bce6961d2f77b348f346926de0bfe2d80a65dc0a862b75c0acfabf6a0d7",
    "now": 1760000010,
    "expect": "accept"
  },
  {
    "id": "call-missing-t",
    "kind": "call",
    "body": "{\"session_token\":\"sess_test_1\",\"round_id\":\"r-1\",\"amount_minor\":250,\"currency\":\"EUR\",\"idempotency_key\":\"ik_vec_1\"}",
    "secrets": [
      "whsec_testvector_current"
    ],
    "header": "v1=d8411bce6961d2f77b348f346926de0bfe2d80a65dc0a862b75c0acfabf6a0d7",
    "now": 1760000010,
    "expect": "reject"
  },
  {
    "id": "call-webhook-format",
    "kind": "call",
    "body": "{\"session_token\":\"sess_test_1\",\"round_id\":\"r-1\",\"amount_minor\":250,\"currency\":\"EUR\",\"idempotency_key\":\"ik_vec_1\"}",
    "secrets": [
      "whsec_testvector_current"
    ],
    "header": "sha256=14533d630f6ae57fd76cfa834d392dd0a432ad234d72748a7ca4e8d273dff8b3",
    "now": 1760000010,
    "expect": "reject"
  },
  {
    "id": "webhook-valid",
    "kind": "webhook",
    "body": "{\"deliveryId\":\"dlv_vec_1\",\"event\":\"player.updated\",\"data\":{\"player_external_id\":\"oh-test-1\"}}",
    "secrets": [
      "whsec_testvector_current"
    ],
    "header": "sha256=0ce6f2aa4171e1319123393b3cbb240dd8be7cc8a7eb575f4aba7e8d3c84b65f",
    "expect": "accept"
  },
  {
    "id": "webhook-rotation-previous-secret",
    "kind": "webhook",
    "body": "{\"deliveryId\":\"dlv_vec_1\",\"event\":\"player.updated\",\"data\":{\"player_external_id\":\"oh-test-1\"}}",
    "secrets": [
      "whsec_testvector_current",
      "whsec_testvector_previous"
    ],
    "header": "sha256=e1282a87ea22ec7babcb13ea0f32fdc5c885fe55078ebb40d56aeecf93586117",
    "expect": "accept"
  },
  {
    "id": "webhook-body-changed",
    "kind": "webhook",
    "body": "{\"deliveryId\":\"dlv_vec_2\",\"event\":\"player.updated\",\"data\":{\"player_external_id\":\"oh-test-1\"}}",
    "secrets": [
      "whsec_testvector_current"
    ],
    "header": "sha256=0ce6f2aa4171e1319123393b3cbb240dd8be7cc8a7eb575f4aba7e8d3c84b65f",
    "expect": "reject"
  },
  {
    "id": "webhook-uppercase-prefix",
    "kind": "webhook",
    "body": "{\"deliveryId\":\"dlv_vec_1\",\"event\":\"player.updated\",\"data\":{\"player_external_id\":\"oh-test-1\"}}",
    "secrets": [
      "whsec_testvector_current"
    ],
    "header": "SHA256=0ce6f2aa4171e1319123393b3cbb240dd8be7cc8a7eb575f4aba7e8d3c84b65f",
    "expect": "reject"
  },
  {
    "id": "webhook-timestamped-format",
    "kind": "webhook",
    "body": "{\"deliveryId\":\"dlv_vec_1\",\"event\":\"player.updated\",\"data\":{\"player_external_id\":\"oh-test-1\"}}",
    "secrets": [
      "whsec_testvector_current"
    ],
    "header": "t=1760000000,v1=5c25a1bcdea72d909d59a3665c418c4f06ee0693189bcf49931a6cf6726cad80",
    "expect": "reject"
  }
]