{
  "info": {
    "_postman_id": "a71a5c00-0000-4a00-8000-atlasapi0001",
    "name": "HelloSafe Atlas API (v1)",
    "description": "Signed sandbox collection for the HelloSafe Atlas travel-insurance API.\n\n## Setup (30 seconds)\n1. Create a **sandbox** key at https://atlas.hellosafe.com/dashboard/api/keys (one click, no review). The secret is shown once.\n2. Open this collection's **Variables** tab and set:\n   - `keyId` -> your `ak_test_...`\n   - `signingSecret` -> your `sk_test_...`\n3. Send any request. That's it.\n\n## How it signs itself\nEvery request is HMAC-SHA256 signed. A collection-level pre-request script builds the message `timestamp.METHOD.path.rawBody`, signs it with your secret (Postman's built-in CryptoJS), and fills the `x-atlas-timestamp` and `x-atlas-signature` headers for you. You never compute a signature by hand.\n\n## Quotes before Links\nEvery Quotes response opens a quoting session (`sessionId`) and this collection saves it for you, so send **Quotes - price a trip** once before **Links**: the Links body carries a SESSION_ID_FROM_QUOTES_RESPONSE placeholder that the signing script replaces with the saved session, exactly like your integration should (one session per traveller; repeat Links calls replay the same subscription).\n\n## Sandbox only\nA sandbox key answers from fixtures: no insurer is called, no real subscription is created, nobody is credited. Safe to send as often as you like. The response shape is identical to live, so an integration built here runs unchanged in production once you swap in a live key.\n\nContract: https://atlas.hellosafe.com/openapi.json  |  Docs: https://atlas.hellosafe.com/platform/api/documentation",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "event": [
    {
      "listen": "prerequest",
      "script": {
        "type": "text/javascript",
        "exec": [
          "// Auto-sign every Atlas request (v2 scheme). Nothing to edit here: just set",
          "// the `keyId` and `signingSecret` collection variables to your sandbox key.",
          "// require('crypto-js') works in BOTH Postman and Insomnia; the bare global",
          "// `CryptoJS` only exists in Postman (Insomnia throws 'CryptoJS is not defined').",
          "const CryptoJS = require('crypto-js');",
          "// The Links body carries the LITERAL placeholder SESSION_ID_FROM_QUOTES_RESPONSE",
          "// rather than a {{variable}}: template engines can't crash on it (Insomnia",
          "// renders undefined variables as a hard error). When Quotes has saved a real",
          "// session, swap it in BEFORE signing; where body.update is unsupported the",
          "// placeholder goes out as-is and the API answers 400 BAD_SESSION_ID with a",
          "// console hint, instead of the client failing before sending anything.",
          "try {",
          "  if (pm.request.body && pm.request.body.mode === 'raw' && pm.request.body.raw &&",
          "      pm.request.body.raw.indexOf('SESSION_ID_FROM_QUOTES_RESPONSE') !== -1) {",
          "    let sid = '';",
          "    try { sid = pm.variables.get('sessionId') || ''; } catch (e) {}",
          "    if (sid && sid.indexOf('qs_') === 0) {",
          "      const swapped = pm.request.body.raw.split('SESSION_ID_FROM_QUOTES_RESPONSE').join(sid);",
          "      try { pm.request.body.update(swapped); } catch (e) {}",
          "    }",
          "  }",
          "} catch (e) {}",
          "const method = pm.request.method.toUpperCase();",
          "// Path = what the server sees as URL.pathname (e.g. /api/v1/travel/meta).",
          "// Postman's getPath() returns it; Insomnia can return '' for a {{var}} URL,",
          "// so fall back to the URL's path segments, then to the resolved raw URL.",
          "let path = '';",
          "try { const gp = pm.request.url.getPath(); if (gp && gp.charAt(0) === '/') path = gp.split('?')[0]; } catch (e) {}",
          "if (!path) { try { if (pm.request.url.path && pm.request.url.path.length) path = '/' + pm.request.url.path.join('/'); } catch (e) {} }",
          "if (!path) {",
          "  let raw = '';",
          "  try { if (typeof insomnia !== 'undefined' && insomnia.request && insomnia.request.url) raw = String(insomnia.request.url); } catch (e) {}",
          "  if (!raw) { try { raw = pm.request.url.toString(); } catch (e) {} }",
          "  if (!raw || raw === '[object Object]') { try { raw = String(pm.request.url); } catch (e) {} }",
          "  raw = pm.variables.replaceIn(raw || '');",
          "  try { path = new URL(raw).pathname; } catch (e) {",
          "    path = raw.replace(new RegExp('^[a-z][a-z0-9+.-]*://[^/]+', 'i'), '').replace(new RegExp('[?#].*'), '') || '/';",
          "  }",
          "}",
          "console.log('[atlas] signing ' + method + ' path=' + JSON.stringify(path));",
          "let body = '';",
          "if (pm.request.body && pm.request.body.mode === 'raw' && pm.request.body.raw) {",
          "  body = pm.variables.replaceIn(pm.request.body.raw);",
          "}",
          "const secret = pm.variables.get('signingSecret');",
          "if (!secret) {",
          "  console.warn('Set the `signingSecret` variable to your sk_test_ value (Variables tab).');",
          "}",
          "const ts = Math.floor(Date.now() / 1000).toString();",
          "// The signature covers the RAW body byte for byte. Re-serialising the JSON",
          "// after signing is the number one cause of INVALID_SIGNATURE.",
          "const message = ts + '.' + method + '.' + path + '.' + body;",
          "const sig = 'v2=' + CryptoJS.HmacSHA256(message, secret).toString(CryptoJS.enc.Hex);",
          "pm.collectionVariables.set('atlasTs', ts);",
          "pm.collectionVariables.set('atlasSig', sig);"
        ]
      }
    },
    {
      "listen": "test",
      "script": {
        "type": "text/javascript",
        "exec": [
          "pm.test('Reached the API (' + pm.response.code + ')', function () {",
          "  pm.expect(pm.response.code).to.not.eql(0);",
          "});",
          "if (pm.response.code === 401) {",
          "  console.warn('401 - check keyId/signingSecret, and that your device clock is within 5 minutes.');",
          "}",
          "if (pm.response.code === 400) {",
          "  try {",
          "    const err = pm.response.json().error;",
          "    if (err === 'MISSING_SESSION_ID' || err === 'BAD_SESSION_ID') {",
          "      console.warn('Send \"Quotes - price a trip\" first: it saves the sessionId this request echoes.');",
          "    }",
          "  } catch (e) {}",
          "}"
        ]
      }
    }
  ],
  "variable": [
    { "key": "baseUrl", "value": "https://atlas.hellosafe.com", "type": "string" },
    { "key": "keyId", "value": "ak_test_REPLACE_ME", "type": "string" },
    { "key": "signingSecret", "value": "sk_test_REPLACE_ME", "type": "string" },
    { "key": "atlasTs", "value": "", "type": "string" },
    { "key": "atlasSig", "value": "", "type": "string" },
    { "key": "sessionId", "value": "", "type": "string" }
  ],
  "item": [
    {
      "name": "Meta - key, quota and vocabulary",
      "request": {
        "method": "GET",
        "header": [
          { "key": "x-atlas-key-id", "value": "{{keyId}}" },
          { "key": "x-atlas-timestamp", "value": "{{atlasTs}}" },
          { "key": "x-atlas-signature", "value": "{{atlasSig}}" }
        ],
        "url": {
          "raw": "{{baseUrl}}/api/v1/travel/meta",
          "host": ["{{baseUrl}}"],
          "path": ["api", "v1", "travel", "meta"]
        },
        "description": "Reference data (trip types, US state codes, guarantee slugs, languages) plus the live state of your key and quota. Free: it does not spend your daily quota. Start here to confirm your key works."
      }
    },
    {
      "name": "Quotes - price a trip",
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "// Save this traveller's quoting session for the Links request. Written",
              "// to both stores: collection variables (Postman) and the environment",
              "// (Insomnia persists that one across requests).",
              "try {",
              "  const sid = pm.response.json().sessionId;",
              "  if (typeof sid === 'string' && sid.indexOf('qs_') === 0) {",
              "    try { pm.collectionVariables.set('sessionId', sid); } catch (e) {}",
              "    try { pm.environment.set('sessionId', sid); } catch (e) {}",
              "    console.log('[atlas] sessionId saved for Links: ' + sid.slice(0, 12) + '…');",
              "  }",
              "} catch (e) { /* non-JSON response: nothing to save */ }"
            ]
          }
        }
      ],
      "request": {
        "method": "POST",
        "header": [
          { "key": "content-type", "value": "application/json" },
          { "key": "x-atlas-key-id", "value": "{{keyId}}" },
          { "key": "x-atlas-timestamp", "value": "{{atlasTs}}" },
          { "key": "x-atlas-signature", "value": "{{atlasSig}}" }
        ],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"trip\": {\n    \"intent\": \"forTourism\",\n    \"startDate\": \"2026-09-15\",\n    \"endDate\": \"2026-09-25\",\n    \"countryResidence\": \"FR\",\n    \"arrivalCountries\": [\"TH\"],\n    \"travellers\": [{ \"age\": 32 }]\n  },\n  \"language\": \"en\"\n}",
          "options": { "raw": { "language": "json" } }
        },
        "url": {
          "raw": "{{baseUrl}}/api/v1/travel/quotes",
          "host": ["{{baseUrl}}"],
          "path": ["api", "v1", "travel", "quotes"]
        },
        "description": "Price one trip against the catalogue. Read-only: nothing is stored, nobody is credited. Returns the priced offers, cheapest first, each with its guarantees. In sandbox these are three deterministic fixture plans."
      }
    },
    {
      "name": "Quotes - price a trip for a US resident",
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "// Save this traveller's quoting session for the Links request. Written",
              "// to both stores: collection variables (Postman) and the environment",
              "// (Insomnia persists that one across requests).",
              "try {",
              "  const sid = pm.response.json().sessionId;",
              "  if (typeof sid === 'string' && sid.indexOf('qs_') === 0) {",
              "    try { pm.collectionVariables.set('sessionId', sid); } catch (e) {}",
              "    try { pm.environment.set('sessionId', sid); } catch (e) {}",
              "    console.log('[atlas] sessionId saved for Links: ' + sid.slice(0, 12) + '…');",
              "  }",
              "} catch (e) { /* non-JSON response: nothing to save */ }"
            ]
          }
        }
      ],
      "request": {
        "method": "POST",
        "header": [
          { "key": "content-type", "value": "application/json" },
          { "key": "x-atlas-key-id", "value": "{{keyId}}" },
          { "key": "x-atlas-timestamp", "value": "{{atlasTs}}" },
          { "key": "x-atlas-signature", "value": "{{atlasSig}}" }
        ],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"trip\": {\n    \"intent\": \"forTourism\",\n    \"startDate\": \"2026-11-20\",\n    \"endDate\": \"2026-11-27\",\n    \"countryResidence\": \"US\",\n    \"stateResidence\": \"NY\",\n    \"arrivalCountries\": [\"MX\"],\n    \"travellers\": [{ \"age\": 41 }],\n    \"currency\": \"USD\"\n  },\n  \"language\": \"en\"\n}",
          "options": { "raw": { "language": "json" } }
        },
        "url": {
          "raw": "{{baseUrl}}/api/v1/travel/quotes",
          "host": ["{{baseUrl}}"],
          "path": ["api", "v1", "travel", "quotes"]
        },
        "description": "The same call for a traveller who lives in the US. `stateResidence` is their state as a USPS code (`NY`, `TX`), one of the 50 states or DC: **Meta** lists them as `trip.usStates`. It is optional and prices nothing. It is echoed in the priced `trip`, and Links stores it on the subscription. It is read only when `countryResidence` is `US`, and a US resident sent with anything outside the list gets 400 BAD_STATE_RESIDENCE. The US territories are countries, not states: a Puerto Rico resident is `countryResidence: \"PR\"` with no state."
      }
    },
    {
      "name": "Links - turn an offer into a tracked sale",
      "request": {
        "method": "POST",
        "header": [
          { "key": "content-type", "value": "application/json" },
          { "key": "x-atlas-key-id", "value": "{{keyId}}" },
          { "key": "x-atlas-timestamp", "value": "{{atlasTs}}" },
          { "key": "x-atlas-signature", "value": "{{atlasSig}}" }
        ],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"sessionId\": \"SESSION_ID_FROM_QUOTES_RESPONSE\",\n  \"trip\": {\n    \"intent\": \"forTourism\",\n    \"startDate\": \"2026-09-15\",\n    \"endDate\": \"2026-09-25\",\n    \"countryResidence\": \"FR\",\n    \"arrivalCountries\": [\"TH\"],\n    \"travellers\": [{ \"age\": 32 }]\n  },\n  \"language\": \"en\",\n  \"offerId\": 900001\n}",
          "options": { "raw": { "language": "json" } }
        },
        "url": {
          "raw": "{{baseUrl}}/api/v1/travel/links",
          "host": ["{{baseUrl}}"],
          "path": ["api", "v1", "travel", "links"]
        },
        "description": "Turn the traveller's session into the tracked subscription link you hand over. Send **Quotes - price a trip** first: it saves the sessionId and the signing script swaps it into the SESSION_ID_FROM_QUOTES_RESPONSE placeholder below (in a client without scripting, paste it by hand; a 400 BAD_SESSION_ID means the placeholder went out unreplaced). `offerId` is the clicked offer's id from the Quotes response (900001 is a sandbox fixture): the live link lands on the presubscribe form with that offer selected. Send it twice and the second answer replays the SAME subscription (replayed: true) — one session, one traveller, one subscription. In sandbox this returns a synthetic sub_sandbox_<id> with ref: null (credits nobody) and stores no real quote."
      }
    },
    {
      "name": "Coach Bilan - what a card does not cover",
      "request": {
        "method": "POST",
        "header": [
          { "key": "content-type", "value": "application/json" },
          { "key": "x-atlas-key-id", "value": "{{keyId}}" },
          { "key": "x-atlas-timestamp", "value": "{{atlasTs}}" },
          { "key": "x-atlas-signature", "value": "{{atlasSig}}" }
        ],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"residence\": \"FR\",\n  \"destinations\": [\"TH\", \"VN\"],\n  \"card\": { \"bin\": \"497010\" },\n  \"trip\": { \"longTrip\": true }\n}",
          "options": { "raw": { "language": "json" } }
        },
        "url": {
          "raw": "{{baseUrl}}/api/v1/coach/bilan",
          "host": ["{{baseUrl}}"],
          "path": ["api", "v1", "coach", "bilan"]
        },
        "description": "Coverage gaps and ranked sell arguments for one traveller, given their bank card. Identify the card by catalogueId (from Coach Cards; not on a sandbox key), by BIN (first 6-8 digits) or by { bank, network, tier }."
      }
    },
    {
      "name": "Coach Cards - the banks and cards the Coach knows",
      "request": {
        "method": "GET",
        "header": [
          { "key": "x-atlas-key-id", "value": "{{keyId}}" },
          { "key": "x-atlas-timestamp", "value": "{{atlasTs}}" },
          { "key": "x-atlas-signature", "value": "{{atlasSig}}" }
        ],
        "url": {
          "raw": "{{baseUrl}}/api/v1/coach/cards?country=FR",
          "host": ["{{baseUrl}}"],
          "path": ["api", "v1", "coach", "cards"],
          "query": [{ "key": "country", "value": "FR" }]
        },
        "description": "The bank cards the Coach knows, per country of residence: each bank, then its cards, each with the catalogueId that names it exactly in Coach Bilan. Free: it does not spend your daily quota. The query string is not signed, only the path."
      }
    }
  ]
}
