{
  "info": {
    "name": "TS External Wallet - Tap Markets",
    "description": "Postman collection for testing the External Wallet integration for Tap Markets.\n\n**Who this is for:** the external wallet provider (operator) building the four callback endpoints - `/authenticate`, `/getbalance`, `/opentrade`, `/closetrade` - that TradesMarter calls during gameplay.\n\n**What it does:** simulates TradesMarter calling into your wallet API. Each POST request is automatically HMAC-SHA256 signed by the collection's pre-request script (no manual header work).\n\n**Setup before first run:**\n1. Set `CUSTODIAL_URL` to your wallet API base URL (e.g. `https://wallet.example.com`).\n2. Set `HMAC_SECRET` to the shared secret TradesMarter provided.\n3. (Optional) Set `launchToken`, `walletToken`, `externalTradeId` as you progress through the flow - `/authenticate` returns a `walletToken` that subsequent calls reuse.\n\n**Signature scheme** (HMAC-SHA256, version 2):\n```\nX-Sig-Version: 2\nX-Timestamp:   <unix seconds>\nX-Nonce:       <32 hex chars>\nX-Signature:   hex(HMAC-SHA256(secret, \"METHOD\\nPATH\\nTIMESTAMP\\nNONCE\\nSHA256_hex(BODY)\"))\n```\nReplay window: +/-60 s on timestamp, 180 s on nonce. Hash the **raw body bytes** - never decode and re-encode.\n\nFull spec: see `TAP_INTEGRATION.md` shared alongside this collection.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "variable": [
    {
      "key": "CUSTODIAL_URL",
      "value": "https://wallet.example.com",
      "type": "string",
      "description": "Base URL of YOUR wallet API. The four endpoints below are POSTed to <CUSTODIAL_URL>/<path>."
    },
    {
      "key": "TS_URL",
      "value": "https://s11.taptrading.com",
      "type": "string",
      "description": "Base URL of the TradesMarter iFrame host TS provisions for you (staging shown)."
    },
    {
      "key": "HMAC_SECRET",
      "value": "replace_with_secret_from_ts",
      "type": "string",
      "description": "Shared secret for HMAC-SHA256 signing. Sent by TS through a secure channel."
    },
    {
      "key": "launchToken",
      "value": "00000000-0000-0000-0000-000000000000",
      "type": "string",
      "description": "One-time launch token YOU mint per Play click. UUID v4 recommended."
    },
    {
      "key": "walletToken",
      "value": "replace_with_token_from_authenticate_response",
      "type": "string",
      "description": "Opaque wallet access token YOU return on /authenticate. TS sends it on every wallet callback."
    },
    {
      "key": "externalTradeId",
      "value": "test-trade-001",
      "type": "string",
      "description": "TS-assigned trade ID. Use as the IDEMPOTENCY KEY in your handlers."
    },
    {
      "key": "siteID",
      "value": "999",
      "type": "string",
      "description": "TS-assigned siteID / partner alias for your account."
    }
  ],
  "auth": {
    "type": "noauth"
  },
  "event": [
    {
      "listen": "prerequest",
      "script": {
        "type": "text/javascript",
        "exec": [
          "// Collection-level pre-request script.",
          "// Signs every POST request with HMAC-SHA256 per TradesMarter spec (v2 scheme).",
          "// GET requests pass through unsigned.",
          "// /authenticate is also passed through unsigned - by vendor contract the",
          "// launchToken IS the credential, so no HMAC headers are sent on that call.",
          "",
          "if (pm.request.method.toUpperCase() !== 'POST') {",
          "  return;",
          "}",
          "",
          "// Resolve {{vars}} in the URL so the path used for canonical-string and",
          "// the /authenticate skip-check both match what will actually be sent.",
          "var fullUrl = pm.variables.replaceIn(pm.request.url.toString());",
          "var pathMatch = fullUrl.match(/^[a-z]+:\\/\\/[^\\/]+(\\/[^?#]*)/i);",
          "var path = pathMatch ? pathMatch[1] : '/';",
          "",
          "if (path === '/authenticate' || path.endsWith('/authenticate')) {",
          "  pm.request.headers.upsert({ key: 'Content-Type', value: 'application/json' });",
          "  console.log('[ts-hmac] skip signing /authenticate (unsigned by vendor contract)');",
          "  return;",
          "}",
          "",
          "var CryptoJS = require('crypto-js');",
          "",
          "var secret = pm.variables.get('HMAC_SECRET');",
          "if (!secret || secret === 'replace_with_secret_from_ts') {",
          "  console.warn('[ts-hmac] HMAC_SECRET is not set; signature will be wrong.');",
          "}",
          "",
          "var method = pm.request.method.toUpperCase();",
          "",
          "var body = '';",
          "if (pm.request.body && pm.request.body.mode === 'raw') {",
          "  body = pm.variables.replaceIn(pm.request.body.raw || '');",
          "}",
          "",
          "var timestamp = Math.floor(Date.now() / 1000).toString();",
          "var nonce = CryptoJS.lib.WordArray.random(16).toString(CryptoJS.enc.Hex);",
          "var bodyHash = CryptoJS.SHA256(body).toString(CryptoJS.enc.Hex);",
          "var canonical = method + '\\n' + path + '\\n' + timestamp + '\\n' + nonce + '\\n' + bodyHash;",
          "var signature = CryptoJS.HmacSHA256(canonical, secret).toString(CryptoJS.enc.Hex);",
          "",
          "// Note: X-Sig-Version is the literal string 'v2', not '2'.",
          "// That's what staging.tradesmarter.io actually sends.",
          "pm.request.headers.upsert({ key: 'X-Sig-Version', value: 'v2' });",
          "pm.request.headers.upsert({ key: 'X-Timestamp',   value: timestamp });",
          "pm.request.headers.upsert({ key: 'X-Nonce',       value: nonce });",
          "pm.request.headers.upsert({ key: 'X-Signature',   value: signature });",
          "pm.request.headers.upsert({ key: 'Content-Type',  value: 'application/json' });",
          "",
          "console.log('[ts-hmac]', method, path, 'ts=' + timestamp, 'nonce=' + nonce.slice(0, 12) + '...');"
        ]
      }
    }
  ],
  "item": [
    {
      "name": "Step 1 - launch (GET to TS index)",
      "request": {
        "method": "GET",
        "header": [
          {
            "key": "Cookie",
            "value": "PHPSESSID={{launchToken}}; userId=u1; UserName=TestUser; userCurrency=1; accountLevel=0",
            "description": "Required cookies for Step 1."
          }
        ],
        "url": {
          "raw": "{{TS_URL}}/ajax/external-wallet/index?token={{launchToken}}&type=tap&currencyCode=USD&language=EN&mode=dev&responseType=json&noheader=true&freePlay=false&mobile=false",
          "host": [
            "{{TS_URL}}"
          ],
          "path": [
            "ajax",
            "external-wallet",
            "index"
          ],
          "query": [
            {
              "key": "token",
              "value": "{{launchToken}}",
              "description": "UUID you mint per Play click."
            },
            {
              "key": "type",
              "value": "tap",
              "description": "Always 'tap' for Tap Markets."
            },
            {
              "key": "currencyCode",
              "value": "USD",
              "description": "ISO alpha, e.g. USD, JPY, USDT."
            },
            {
              "key": "language",
              "value": "EN",
              "description": "2-letter UPPERCASE."
            },
            {
              "key": "mode",
              "value": "dev",
              "description": "Keep 'dev' during integration."
            },
            {
              "key": "responseType",
              "value": "json"
            },
            {
              "key": "noheader",
              "value": "true"
            },
            {
              "key": "freePlay",
              "value": "false"
            },
            {
              "key": "mobile",
              "value": "false"
            }
          ]
        },
        "description": "Step 1 of the launch flow. YOUR backend redirects the player's browser to this URL. TS holds the response open until it has called your `/authenticate` (Step 2) and you returned `status:\"Ok\"`. Final response is the authenticated iFrame URL.\n\nThis request is NOT HMAC-signed (it's a browser navigation, not a server-to-server callback)."
      },
      "response": []
    },
    {
      "name": "/authenticate (Step 2)",
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('200 OK', () => pm.response.to.have.status(200));",
              "pm.test('status is Ok', () => {",
              "  const j = pm.response.json();",
              "  pm.expect(j.status).to.eql('Ok');",
              "});",
              "pm.test('returns walletToken', () => {",
              "  const j = pm.response.json();",
              "  pm.expect(j.token, 'response.token').to.be.a('string').and.not.be.empty;",
              "  pm.collectionVariables.set('walletToken', j.token);",
              "});"
            ]
          }
        }
      ],
      "request": {
        "method": "POST",
        "header": [],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"launchToken\": \"{{launchToken}}\"\n}",
          "options": {
            "raw": {
              "language": "json"
            }
          }
        },
        "url": {
          "raw": "{{CUSTODIAL_URL}}/authenticate",
          "host": [
            "{{CUSTODIAL_URL}}"
          ],
          "path": [
            "authenticate"
          ]
        },
        "description": "TS POSTs the launch token from Step 1 to your `/authenticate`. You look up the launchToken \u2192 user mapping you persisted, mint a `walletToken`, and return user identity + balance.\n\n**Expected response shape:**\n```json\n{\n  \"status\": \"Ok\",\n  \"token\": \"<walletToken>\",\n  \"userId\": \"u1\",\n  \"userName\": \"TestUser\",\n  \"balance\": \"1000\",\n  \"currency\": \"USD\"\n}\n```\n\nThe test script auto-saves the returned `token` into the collection variable `walletToken` so subsequent requests pick it up automatically."
      },
      "response": [
        {
          "name": "200 Ok",
          "originalRequest": {
            "method": "POST",
            "header": [],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"launchToken\": \"{{launchToken}}\"\n}"
            },
            "url": {
              "raw": "{{CUSTODIAL_URL}}/authenticate",
              "host": [
                "{{CUSTODIAL_URL}}"
              ],
              "path": [
                "authenticate"
              ]
            }
          },
          "status": "OK",
          "code": 200,
          "_postman_previewlanguage": "json",
          "header": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "body": "{\n  \"status\": \"Ok\",\n  \"token\": \"f27c9a67d8ba714020bb7edd585ac174c5b10edc595c7715\",\n  \"userId\": \"u1\",\n  \"userName\": \"TestUser\",\n  \"balance\": \"1000\",\n  \"currency\": \"USD\"\n}"
        }
      ]
    },
    {
      "name": "/getbalance",
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('200 OK', () => pm.response.to.have.status(200));",
              "pm.test('balance is a string', () => {",
              "  const j = pm.response.json();",
              "  pm.expect(j.balance, 'response.balance').to.be.a('string');",
              "});",
              "pm.test('currency matches request', () => {",
              "  const j = pm.response.json();",
              "  pm.expect(j.currency).to.eql('USD');",
              "});"
            ]
          }
        }
      ],
      "request": {
        "method": "POST",
        "header": [],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"token\": \"{{walletToken}}\",\n  \"amount\": \"10\",\n  \"currency\": \"USD\"\n}",
          "options": {
            "raw": {
              "language": "json"
            }
          }
        },
        "url": {
          "raw": "{{CUSTODIAL_URL}}/getbalance",
          "host": [
            "{{CUSTODIAL_URL}}"
          ],
          "path": [
            "getbalance"
          ]
        },
        "description": "Called before each `/opentrade` to verify the player has sufficient funds for the requested stake. TS does the comparison on its side; you just return the current balance.\n\n**Expected response:**\n```json\n{ \"status\": \"Ok\", \"balance\": \"1000\", \"currency\": \"USD\" }\n```\n\nIf you want to fail fast on insufficient funds:\n```json\n{ \"status\": \"InsufficientFunds\", \"balance\": \"5\", \"currency\": \"USD\" }\n```"
      },
      "response": [
        {
          "name": "200 Ok",
          "originalRequest": {
            "method": "POST",
            "header": [],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"token\": \"{{walletToken}}\",\n  \"amount\": \"10\",\n  \"currency\": \"USD\"\n}"
            },
            "url": {
              "raw": "{{CUSTODIAL_URL}}/getbalance",
              "host": [
                "{{CUSTODIAL_URL}}"
              ],
              "path": [
                "getbalance"
              ]
            }
          },
          "status": "OK",
          "code": 200,
          "_postman_previewlanguage": "json",
          "header": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "body": "{ \"status\": \"Ok\", \"balance\": \"1000\", \"currency\": \"USD\" }"
        }
      ]
    },
    {
      "name": "/opentrade - Tap",
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('200 OK', () => pm.response.to.have.status(200));",
              "pm.test('status is Ok', () => {",
              "  const j = pm.response.json();",
              "  pm.expect(j.status).to.eql('Ok');",
              "});",
              "pm.test('balance reduced by stake (idempotent guard)', () => {",
              "  // Run this once, then re-send the request - balance MUST be the same on both runs.",
              "  const j = pm.response.json();",
              "  pm.expect(j.balance, 'response.balance').to.be.a('string');",
              "  console.log('[opentrade] balance after open =', j.balance);",
              "});"
            ]
          }
        }
      ],
      "request": {
        "method": "POST",
        "header": [],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"token\": \"{{walletToken}}\",\n  \"amount\": \"10\",\n  \"currency\": \"USD\",\n  \"externalTradeType\": \"tap\",\n  \"externalTradeId\": \"{{externalTradeId}}\",\n  \"data\": [{\n    \"userID\":       \"u1\",\n    \"currency\":     \"USD\",\n    \"instrumentID\": \"139\",\n    \"tStart\":       \"2026-06-08T12:00:00.000Z\",\n    \"tEnd\":         \"2026-06-08T12:00:02.000Z\",\n    \"pMin\":         \"65430.00\",\n    \"pMax\":         \"65432.00\",\n    \"stake\":        \"10\",\n    \"payoutRatio\":  \"1.7\"\n  }]\n}",
          "options": {
            "raw": {
              "language": "json"
            }
          }
        },
        "url": {
          "raw": "{{CUSTODIAL_URL}}/opentrade",
          "host": [
            "{{CUSTODIAL_URL}}"
          ],
          "path": [
            "opentrade"
          ]
        },
        "description": "Reserve the player's stake when they tap a Tap-Markets cell.\n\n**MUST be idempotent by `externalTradeId`.** TS may retry on timeout/network error - return the same response without double-debiting.\n\n**Tap-specific fields in `data[0]`:**\n- `tStart` / `tEnd` - ISO-8601 UTC, ms precision. Difference = tap size (2 / 4 / 8 s).\n- `pMin` / `pMax` - cell's price band, opaque to you.\n- `payoutRatio` - profit multiplier locked at tap time.\n\n**Expected response:**\n```json\n{ \"status\": \"Ok\", \"balance\": \"990\", \"currency\": \"USD\" }\n```\n\nTest idempotency: send this request twice. Balance must be `990` after both runs (not `980`)."
      },
      "response": [
        {
          "name": "200 Ok",
          "originalRequest": {
            "method": "POST",
            "header": [],
            "body": {
              "mode": "raw",
              "raw": "{...see request body...}"
            },
            "url": {
              "raw": "{{CUSTODIAL_URL}}/opentrade",
              "host": [
                "{{CUSTODIAL_URL}}"
              ],
              "path": [
                "opentrade"
              ]
            }
          },
          "status": "OK",
          "code": 200,
          "_postman_previewlanguage": "json",
          "header": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "body": "{ \"status\": \"Ok\", \"balance\": \"990\", \"currency\": \"USD\" }"
        },
        {
          "name": "InsufficientFunds",
          "originalRequest": {
            "method": "POST",
            "header": [],
            "body": {
              "mode": "raw",
              "raw": "{...see request body...}"
            },
            "url": {
              "raw": "{{CUSTODIAL_URL}}/opentrade",
              "host": [
                "{{CUSTODIAL_URL}}"
              ],
              "path": [
                "opentrade"
              ]
            }
          },
          "code": 200,
          "_postman_previewlanguage": "json",
          "header": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "body": "{ \"status\": \"InsufficientFunds\", \"balance\": \"5\", \"currency\": \"USD\" }"
        }
      ]
    },
    {
      "name": "/closetrade - Tap (win)",
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('200 OK', () => pm.response.to.have.status(200));",
              "pm.test('status is Ok', () => {",
              "  const j = pm.response.json();",
              "  pm.expect(j.status).to.eql('Ok');",
              "});",
              "pm.test('balance reflects win = stake + payout', () => {",
              "  const j = pm.response.json();",
              "  console.log('[closetrade] balance after settle =', j.balance);",
              "});"
            ]
          }
        }
      ],
      "request": {
        "method": "POST",
        "header": [],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"token\": \"{{walletToken}}\",\n  \"amount\": \"17\",\n  \"currency\": \"USD\",\n  \"externalTradeType\": \"tap\",\n  \"externalTradeId\": \"{{externalTradeId}}\",\n  \"data\": [{\n    \"tradeID\":        \"{{externalTradeId}}\",\n    \"userID\":         \"u1\",\n    \"stake\":          \"10.0000000000\",\n    \"payout\":         \"70\",\n    \"rebate\":         \"0\",\n    \"instrumentID\":   \"139\",\n    \"expiryPrice\":    \"65431.50\",\n    \"touchPrice\":     \"65431.40\",\n    \"pnl\":            \"7.00\",\n    \"returnedAmount\": \"17.00\"\n  }]\n}",
          "options": {
            "raw": {
              "language": "json"
            }
          }
        },
        "url": {
          "raw": "{{CUSTODIAL_URL}}/closetrade",
          "host": [
            "{{CUSTODIAL_URL}}"
          ],
          "path": [
            "closetrade"
          ]
        },
        "description": "Settle a trade. **Always credit by `amount`, not by `pnl` or `returnedAmount`.** TS computes `amount` based on outcome:\n\n| Outcome | `amount` |\n|---|---|\n| Win (in the money) | `stake + payout` |\n| Tie (at the money) | `stake` (refund) |\n| Loss (out of the money) | `0` |\n\nThis sample is a **win**: stake=10, payout=7, amount=17. Player balance should increase by 17.\n\n**MUST be idempotent by `externalTradeId`.** Send this request twice - balance must be the same on both runs.\n\n`data.touchPrice` is informational only - never use for wallet math."
      },
      "response": [
        {
          "name": "200 Ok",
          "originalRequest": {
            "method": "POST",
            "header": [],
            "body": {
              "mode": "raw",
              "raw": "{...see request body...}"
            },
            "url": {
              "raw": "{{CUSTODIAL_URL}}/closetrade",
              "host": [
                "{{CUSTODIAL_URL}}"
              ],
              "path": [
                "closetrade"
              ]
            }
          },
          "status": "OK",
          "code": 200,
          "_postman_previewlanguage": "json",
          "header": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "body": "{ \"status\": \"Ok\", \"balance\": \"1007\", \"currency\": \"USD\" }"
        }
      ]
    },
    {
      "name": "/closetrade - Tap (loss)",
      "request": {
        "method": "POST",
        "header": [],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"token\": \"{{walletToken}}\",\n  \"amount\": \"0\",\n  \"currency\": \"USD\",\n  \"externalTradeType\": \"tap\",\n  \"externalTradeId\": \"{{externalTradeId}}\",\n  \"data\": [{\n    \"tradeID\":        \"{{externalTradeId}}\",\n    \"userID\":         \"u1\",\n    \"stake\":          \"10.0000000000\",\n    \"payout\":         \"0\",\n    \"instrumentID\":   \"139\",\n    \"expiryPrice\":    \"65428.00\",\n    \"pnl\":            \"-10.00\",\n    \"returnedAmount\": \"0\"\n  }]\n}",
          "options": {
            "raw": {
              "language": "json"
            }
          }
        },
        "url": {
          "raw": "{{CUSTODIAL_URL}}/closetrade",
          "host": [
            "{{CUSTODIAL_URL}}"
          ],
          "path": [
            "closetrade"
          ]
        },
        "description": "Loss-case settlement: `amount = 0`. Release the reservation without crediting any funds back. Player balance stays at whatever it was AFTER the `/opentrade` debit.\n\nDuplicate `externalTradeId` ID with `/closetrade - Tap (win)` to demonstrate that TS sends ONE close per trade - pick win OR loss as the active scenario per test run."
      },
      "response": []
    },
    {
      "name": "/closetrade - Tap (tie / cancel)",
      "request": {
        "method": "POST",
        "header": [],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"token\": \"{{walletToken}}\",\n  \"amount\": \"10\",\n  \"currency\": \"USD\",\n  \"externalTradeType\": \"tap\",\n  \"externalTradeId\": \"{{externalTradeId}}\",\n  \"data\": [{\n    \"tradeID\":        \"{{externalTradeId}}\",\n    \"userID\":         \"u1\",\n    \"stake\":          \"10.0000000000\",\n    \"payout\":         \"0\",\n    \"instrumentID\":   \"139\",\n    \"expiryPrice\":    \"65431.00\",\n    \"pnl\":            \"0.00\",\n    \"returnedAmount\": \"10.00\"\n  }]\n}",
          "options": {
            "raw": {
              "language": "json"
            }
          }
        },
        "url": {
          "raw": "{{CUSTODIAL_URL}}/closetrade",
          "host": [
            "{{CUSTODIAL_URL}}"
          ],
          "path": [
            "closetrade"
          ]
        },
        "description": "Tie-case (or window-boundary cancellation): `amount = stake`. The reservation is fully refunded - player ends up where they started."
      },
      "response": []
    },
    {
      "name": "Smoke test - bad signature (expect rejection)",
      "event": [
        {
          "listen": "prerequest",
          "script": {
            "type": "text/javascript",
            "exec": [
              "// Override the auto-generated X-Signature with a known-bad value.",
              "// Your endpoint MUST reject this with InvalidDigest or similar.",
              "pm.request.headers.upsert({ key: 'X-Signature', value: '0000000000000000000000000000000000000000000000000000000000000000' });"
            ]
          }
        },
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('Bad signature is rejected (not 200 Ok)', () => {",
              "  const ok = pm.response.code !== 200 || (pm.response.json().status !== 'Ok');",
              "  pm.expect(ok, 'request with tampered X-Signature must be rejected').to.be.true;",
              "});"
            ]
          }
        }
      ],
      "request": {
        "method": "POST",
        "header": [],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"token\": \"{{walletToken}}\",\n  \"amount\": \"10\",\n  \"currency\": \"USD\"\n}",
          "options": {
            "raw": {
              "language": "json"
            }
          }
        },
        "url": {
          "raw": "{{CUSTODIAL_URL}}/getbalance",
          "host": [
            "{{CUSTODIAL_URL}}"
          ],
          "path": [
            "getbalance"
          ]
        },
        "description": "Diagnostic: sends a `/getbalance` with a tampered `X-Signature` (all zeros). Your endpoint MUST reject it - typically with `{ \"status\": \"InvalidDigest\" }` or a 401. If this test PASSES (your endpoint accepted the bad signature), your HMAC verification is broken - fix it before going to staging."
      },
      "response": []
    }
  ]
}