{
  "info": {
    "_postman_id": "f7b3a821-9d34-4c5a-b8f1-7f4b8a0c1d22",
    "name": "Kaito Rail API",
    "description": "Partner-facing Postman cookbook for the Kaito Rail M2M API.\n\n## Setup\n1. Open this collection's **Variables** tab and fill in:\n   - `apiKeyId` — your `rk_test_…` (or `rk_live_…`) identifier.\n   - `hmacSecret` — your per-key HMAC secret (kept locally; never sent on the wire).\n   - `baseUrl` — `https://api.dev.kaito.io/v1` for dev, `https://api.kaito.io/v1` for production.\n   - `targetAccountId` — a destination account id that belongs to your client (create one first with `POST /accounts`).\n   - `senderWalletAddress` — only needed for crypto-origin quote examples.\n2. Run any request. The collection's **pre-request script** signs every request automatically.\n\n## How the auth works\nThe Rail API uses an API-key + per-request HMAC scheme (no Bearer/JWT). It is not expressible as a built-in Postman `auth` type, so the signing lives in the collection-level **pre-request script** (see the Pre-request Script tab). Every child request inherits it — no per-request setup needed.\n\nFor each request, the script computes:\n\n  canonical = `<timestamp>.<METHOD>.<rawPath>.<rawQueryString>.<traceId>.<sha256(body)>`\n  signature = HMAC-SHA256(canonical, hmacSecret) hex\n\nAnd sets these headers: `x-kaito-api-key`, `x-kaito-timestamp`, `x-kaito-trace-id`, `x-kaito-content`, `x-kaito-signature`.\n\n`rawPath` is the URL path with `canonicalPathBaseToStrip` (`/v1`) removed, so it matches API Gateway's `rawPath` after the `/v1` API mapping key is stripped.\n\n## What the API exposes\n- **Rails:** `GET /rails`\n- **Accounts:** list / get / create / validate / patch / delete\n- **Quotes:** `POST /quotes` (origin = `sourceCountry` **XOR** `senderWalletAddress`)\n- **Orders:** create from quote, list, get, `deposit-reported` (body-less)\n\n## Public-contract guarantees\nQuote/order responses are intentionally minimal. The following fields are **never** present: `nextStep`, `operationMode`, `sourcePartner`, `destinationPartner`, `availableActions`, `history`, `eventStatus`, downstream client blobs, filter echoes, action-result item wrappers. `GET /orders?orderId=…` is rejected with 400 (use `GET /orders/{orderId}`). `PUT /accounts/{id}` returns 405 (use `PATCH`).",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "noauth"
  },
  "variable": [
    {
      "key": "baseUrl",
      "value": "https://api.dev.kaito.io/v1",
      "type": "string"
    },
    {
      "key": "canonicalPathBaseToStrip",
      "value": "/v1",
      "type": "string"
    },
    {
      "key": "apiKeyId",
      "value": "rk_test_REPLACE_ME",
      "type": "string"
    },
    {
      "key": "hmacSecret",
      "value": "sk_REPLACE_ME",
      "type": "secret"
    },
    {
      "key": "targetAccountId",
      "value": "REPLACE_WITH_ACCOUNT_ID",
      "type": "string"
    },
    {
      "key": "targetAccountGtId",
      "value": "REPLACE_WITH_GT_ACCOUNT_ID",
      "type": "string"
    },
    {
      "key": "accountId",
      "value": "REPLACE_WITH_ACCOUNT_ID",
      "type": "string"
    },
    {
      "key": "senderWalletAddress",
      "value": "0x0000000000000000000000000000000000000000",
      "type": "string"
    },
    {
      "key": "quoteId",
      "value": "",
      "type": "string"
    },
    {
      "key": "orderId",
      "value": "",
      "type": "string"
    },
    {
      "key": "traceId",
      "value": "",
      "type": "string"
    }
  ],
  "event": [
    {
      "listen": "prerequest",
      "script": {
        "type": "text/javascript",
        "exec": [
          "const method = pm.request.method.toUpperCase();",
          "",
          "// Postman's send-time substitution resolves {{baseUrl}} in the host but",
          "// NOT {{orderId}} in path segments (it parses them as path variables with",
          "// empty values, so the raw name `orderId` reaches the wire). Do the",
          "// template substitution ourselves on the raw URL string and reassign",
          "// pm.request.url with the fully resolved literal.",
          "// pm.variables.get(name) can be SHADOWED by Postman's auto-created",
          "// path-variable placeholders, which default the value to the var NAME",
          "// (so {{orderId}} -> 'orderId' literal on the wire). Read from",
          "// collection/environment/globals explicitly to bypass that shadow.",
          "const lookupVar = function (name) {",
          "  var v;",
          "  try { v = pm.collectionVariables.get(name); } catch (e) {}",
          "  if (v === undefined || v === null || v === '') {",
          "    try { v = pm.environment && pm.environment.get(name); } catch (e) {}",
          "  }",
          "  if (v === undefined || v === null || v === '') {",
          "    try { v = pm.globals && pm.globals.get(name); } catch (e) {}",
          "  }",
          "  return v;",
          "};",
          "const rawUrlStr = pm.request.url.toString();",
          "const resolvedUrl = rawUrlStr.replace(/\\{\\{([\\w.-]+)\\}\\}/g, function (match, name) {",
          "  const value = lookupVar(name);",
          "  if (value === undefined || value === null || value === '') return match;",
          "  return String(value);",
          "});",
          "if (/\\{\\{[^}]+\\}\\}/.test(resolvedUrl)) {",
          "  throw new Error('Unresolved Postman variable in URL: ' + resolvedUrl + '. Set the missing collection variable.');",
          "}",
          "if (/\\/\\//.test(resolvedUrl.replace(/^https?:\\/\\//, ''))) {",
          "  throw new Error('Empty path segment in URL: ' + resolvedUrl + '. Set the missing variable (orderId/accountId/etc), e.g. by running Create quote -> Create order from quote first.');",
          "}",
          "pm.request.url = resolvedUrl;",
          "console.log('[kaito-rail] raw:', rawUrlStr, '-> resolved:', resolvedUrl);",
          "",
          "const timestamp = Math.floor(Date.now() / 1000).toString();",
          "const traceId = 'trc_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10);",
          "pm.variables.set('traceId', traceId);",
          "",
          "let body = '';",
          "if (pm.request.body && pm.request.body.mode === 'raw' && pm.request.body.raw !== undefined) {",
          "  body = pm.variables.replaceIn(pm.request.body.raw || '');",
          "}",
          "const contentSha256 = CryptoJS.SHA256(CryptoJS.enc.Utf8.parse(body)).toString(CryptoJS.enc.Hex);",
          "",
          "// Parse the resolved URL with the native WHATWG URL so the signed path",
          "// is exactly what hits the wire (bypasses any pm.request.url getPath/",
          "// getQueryString quirks around path variables).",
          "const _parsed = new URL(resolvedUrl);",
          "let rawPath = _parsed.pathname || '/';",
          "const stripPrefix = pm.variables.get('canonicalPathBaseToStrip');",
          "if (stripPrefix && rawPath === stripPrefix) {",
          "  rawPath = '/';",
          "} else if (stripPrefix && rawPath.startsWith(stripPrefix + '/')) {",
          "  rawPath = rawPath.substring(stripPrefix.length);",
          "}",
          "rawPath = rawPath || '/';",
          "const rawQueryString = _parsed.search ? _parsed.search.substring(1) : '';",
          "const canonical = [timestamp, method, rawPath, rawQueryString, traceId, contentSha256].join('.');",
          "const secret = pm.variables.get('hmacSecret') || '';",
          "const signature = CryptoJS.HmacSHA256(canonical, secret).toString(CryptoJS.enc.Hex);",
          "",
          "pm.request.headers.upsert({ key: 'x-kaito-api-key', value: pm.variables.get('apiKeyId') || '' });",
          "pm.request.headers.upsert({ key: 'x-kaito-timestamp', value: timestamp });",
          "pm.request.headers.upsert({ key: 'x-kaito-trace-id', value: traceId });",
          "pm.request.headers.upsert({ key: 'x-kaito-content', value: contentSha256 });",
          "pm.request.headers.upsert({ key: 'x-kaito-signature', value: signature });",
          "pm.request.headers.upsert({ key: 'content-type', value: 'application/json' });"
        ]
      }
    }
  ],
  "item": [
    {
      "name": "Rails",
      "item": [
        {
          "name": "List rails",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/rails",
            "description": "GET /rails — returns enabled corridor configs for the authenticated client."
          }
        }
      ]
    },
    {
      "name": "Accounts",
      "item": [
        {
          "name": "List accounts",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/accounts",
            "description": "GET /accounts. Optional `?country=<ISO-2>` filter. Scoped server-side by the authorizer-resolved clientId."
          }
        },
        {
          "name": "List accounts by country",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/accounts?country=GT",
              "host": ["{{baseUrl}}"],
              "path": ["accounts"],
              "query": [{ "key": "country", "value": "GT" }]
            },
            "description": "GET /accounts?country=GT. Country is normalized to uppercase server-side."
          }
        },
        {
          "name": "Get account",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/accounts/{{accountId}}",
            "description": "GET /accounts/{accountId}. 404 if absent for this client."
          }
        },
        {
          "name": "Validate account",
          "request": {
            "method": "POST",
            "header": [],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"type\": \"local_gt\",\n  \"country\": \"GT\",\n  \"beneficiaryName\": \"QA Local GT\",\n  \"accountNumber\": \"000000001\"\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": "{{baseUrl}}/accounts/validate",
            "description": "POST /accounts/validate. Delegates to Portal's validate Lambda; does not persist."
          }
        },
        {
          "name": "Create local GT account",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "try {",
                  "  const json = pm.response.json();",
                  "  const created = json.data && json.data.account;",
                  "  if (created && created.id) pm.collectionVariables.set('accountId', created.id);",
                  "} catch (e) {}"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"type\": \"local_gt\",\n  \"country\": \"GT\",\n  \"name\": \"My Guatemala Test\",\n  \"beneficiaryName\": \"QA Local GT\",\n  \"bankName\": \"Banco Industrial\",\n  \"accountNumber\": \"000000001\",\n  \"localAccountType\": \"checking\"\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": "{{baseUrl}}/accounts",
            "description": "POST /accounts. Rail API injects `paymentRails=[{name:'kaito', status:'active'}]` for `local_gt`/`local_pa` before invoking Portal."
          }
        },
        {
          "name": "Create local PA account",
          "request": {
            "method": "POST",
            "header": [],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"type\": \"local_pa\",\n  \"country\": \"PA\",\n  \"name\": \"My Panama Test\",\n  \"beneficiaryName\": \"QA Local PA\",\n  \"bankName\": \"Banco General\",\n  \"accountNumber\": \"000000002\",\n  \"localAccountType\": \"checking\"\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": "{{baseUrl}}/accounts",
            "description": "POST /accounts for Panama local account."
          }
        },
        {
          "name": "Create SoyFri GT account",
          "request": {
            "method": "POST",
            "header": [],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"type\": \"soyfri_gt\",\n  \"country\": \"GT\",\n  \"soyFriUsername\": \"qa_user\",\n  \"soyFriValidatedName\": \"QA SoyFri User\",\n  \"soyFriValidatedStatus\": \"active\"\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": "{{baseUrl}}/accounts",
            "description": "POST /accounts for SoyFri. Rail API pre-validates with `username = soyFriUsername` and injects `paymentRails=[{name:'soyfri', status:'active'}]`."
          }
        },
        {
          "name": "Update account (PATCH)",
          "request": {
            "method": "PATCH",
            "header": [],
            "url": "{{baseUrl}}/accounts/{{accountId}}",
            "description": "PATCH /accounts/{accountId}. Partial update. `PUT` returns 405.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Updated Account\",\n  \"beneficiaryName\": \"Updated Beneficiary\"\n}",
              "options": { "raw": { "language": "json" } }
            }
          }
        },
        {
          "name": "Delete account",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": "{{baseUrl}}/accounts/{{accountId}}",
            "description": "DELETE /accounts/{accountId}."
          }
        }
      ]
    },
    {
      "name": "Quotes",
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "try {",
              "  const json = pm.response.json();",
              "  const data = json.data || json;",
              "  const quoteId = data.quoteId || data.id || (data.quote && (data.quote.quoteId || data.quote.id));",
              "  if (quoteId) pm.collectionVariables.set('quoteId', quoteId);",
              "} catch (e) {}"
            ]
          }
        }
      ],
      "item": [
        {
          "name": "Create quote — kaito → kaito (fiat GT → fiat PA)",
          "request": {
            "method": "POST",
            "header": [],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"sourceCountry\": \"GT\",\n  \"targetAccountId\": \"{{targetAccountId}}\",\n  \"amount\": 20\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": "{{baseUrl}}/quotes",
            "description": "kaito → kaito (fiat-fiat) GT → PA. Set `targetAccountId` to a PA `local_pa` account. Rail API resolves Kaito's source bank from a per-country catalog using `sourceCountry`."
          }
        },
        {
          "name": "Create quote — kaito → kaito (fiat PA → fiat GT)",
          "request": {
            "method": "POST",
            "header": [],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"sourceCountry\": \"PA\",\n  \"targetAccountId\": \"{{targetAccountGtId}}\",\n  \"amount\": 20\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": "{{baseUrl}}/quotes",
            "description": "kaito → kaito (fiat-fiat) PA → GT. Set `targetAccountGtId` to a GT `local_gt` account."
          }
        },
        {
          "name": "Create quote — kaito → soyfri (fiat PA → soyfri GT)",
          "request": {
            "method": "POST",
            "header": [],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"sourceCountry\": \"PA\",\n  \"targetAccountId\": \"{{targetAccountGtId}}\",\n  \"amount\": 20\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": "{{baseUrl}}/quotes",
            "description": "kaito → soyfri. Same shape as kaito → kaito; what changes is the destination: `targetAccountGtId` must point to a `soyfri_gt` account."
          }
        },
        {
          "name": "Create quote — crypto → kaito (wallet → fiat PA)",
          "request": {
            "method": "POST",
            "header": [],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"senderWalletAddress\": \"{{senderWalletAddress}}\",\n  \"targetAccountId\": \"{{targetAccountId}}\",\n  \"amount\": 20\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": "{{baseUrl}}/quotes",
            "description": "crypto → kaito (crypto-fiat). Origin = `senderWalletAddress`. Destination = a `local_*` account. Do NOT also send `sourceCountry`; rail-api rejects ambiguous origin."
          }
        },
        {
          "name": "Create quote — crypto → soyfri (wallet → soyfri GT)",
          "request": {
            "method": "POST",
            "header": [],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"senderWalletAddress\": \"{{senderWalletAddress}}\",\n  \"targetAccountId\": \"{{targetAccountGtId}}\",\n  \"amount\": 20\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": "{{baseUrl}}/quotes",
            "description": "crypto → soyfri. Origin = `senderWalletAddress`. Destination = a `soyfri_gt` account."
          }
        }
      ]
    },
    {
      "name": "Orders",
      "item": [
        {
          "name": "Create order from quote",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "try {",
                  "  const json = pm.response.json();",
                  "  const data = json.data || json;",
                  "  const orderId = data.orderId || data.id || data.quoteId || (data.order && (data.order.orderId || data.order.id));",
                  "  if (orderId) pm.collectionVariables.set('orderId', orderId);",
                  "} catch (e) {}"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"quoteId\": \"{{quoteId}}\"\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": "{{baseUrl}}/orders",
            "description": "POST /orders. Public conversion of a previously created quote. Returns the minimal public order projection: `orderId`, `quoteId`, `status`, `updatedAt`, and `depositInstructions` when upstream provides them."
          }
        },
        {
          "name": "List orders",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/orders?limit=10",
              "host": ["{{baseUrl}}"],
              "path": ["orders"],
              "query": [{ "key": "limit", "value": "10" }]
            },
            "description": "GET /orders. Summary list. `GET /orders?orderId=…` is intentionally rejected with 400; use `GET /orders/{orderId}` for single-order lookup."
          }
        },
        {
          "name": "Get order",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/orders/{{orderId}}",
            "description": "GET /orders/{orderId}. Detail projection: `orderId`, `status`, `createdAt`, `updatedAt`, `corridor`, `financials`, `onrampDeposit`, `offrampDeposit`."
          }
        },
        {
          "name": "Report deposit",
          "request": {
            "method": "POST",
            "header": [],
            "url": "{{baseUrl}}/orders/{{orderId}}/deposit-reported",
            "description": "POST /orders/{orderId}/deposit-reported. Body-less command; the action is the same regardless of flow type (kaito↔kaito, kaito→soyfri, crypto→kaito, crypto→soyfri). Maps internally to Portal's `mark_deposit_sent` with `operatorId=rail-api`. Order transitions: status `initial` → `pending`."
          }
        }
      ]
    },
    {
      "name": "Diagnostics",
      "item": [
        {
          "name": "Unsupported route should return 405",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{baseUrl}}/orders/{{orderId}}/unsupported",
            "description": "Negative sanity check for canonical error envelope and METHOD_NOT_ALLOWED."
          }
        },
        {
          "name": "List orders with orderId query should return 400",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/orders?orderId={{orderId}}",
              "host": ["{{baseUrl}}"],
              "path": ["orders"],
              "query": [{ "key": "orderId", "value": "{{orderId}}" }]
            },
            "description": "GET /orders?orderId=… is unsupported; use GET /orders/{orderId}."
          }
        },
        {
          "name": "Deposit reported with body should return 400",
          "request": {
            "method": "POST",
            "header": [],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"unexpected\": true\n}",
              "options": { "raw": { "language": "json" } }
            },
            "url": "{{baseUrl}}/orders/{{orderId}}/deposit-reported",
            "description": "POST /orders/{orderId}/deposit-reported rejects non-empty bodies; the action is body-less."
          }
        }
      ]
    }
  ]
}
