--- title: "Connect: adapter envelope spec" description: "The Operator Contract v1 envelope the adapter implements, written for ports to other languages, with signature test vectors: raw body, secret, header and expected verdict." --- 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](/connect/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 `. 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 = "." + 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" } ] ```