{
  "info": {
    "_postman_id": "f72c9bde-9b78-4f5a-b53d-b5fe75f7df55",
    "name": "RxApp Contacts API v1",
    "description": "Read-only examples for external CRM integrations. Set crmApiKey only as a local Current value. Never export a populated collection.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      { "key": "token", "value": "{{crmApiKey}}", "type": "string" }
    ]
  },
  "variable": [
    { "key": "baseUrl", "value": "https://api.rxapp.io", "type": "string" },
    { "key": "crmApiKey", "value": "", "type": "string" },
    { "key": "limit", "value": "100", "type": "string" },
    { "key": "contactId", "value": "", "type": "string" },
    { "key": "cursor", "value": "", "type": "string" },
    { "key": "syncWatermark", "value": "", "type": "string" },
    { "key": "watermark", "value": "", "type": "string" }
  ],
  "item": [
    {
      "name": "Getting started",
      "description": "Run the first request, repeat the continuation while cursor is populated, then inspect the captured contact.",
      "item": [
        {
          "name": "01 - Start full contact sync",
          "request": {
            "method": "GET",
            "header": [{ "key": "Accept", "value": "application/json" }],
            "description": "Starts a stable contact snapshot. The script captures the first contact ID, the snapshot watermark, and any continuation cursor. It commits watermark only when this is the final page.",
            "url": {
              "raw": "{{baseUrl}}/v1/contacts?limit={{limit}}",
              "host": ["{{baseUrl}}"],
              "path": ["v1", "contacts"],
              "query": [{ "key": "limit", "value": "{{limit}}" }]
            }
          },
          "event": [
            {
              "listen": "prerequest",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const limit = Number(pm.collectionVariables.get('limit'));",
                  "if (!Number.isInteger(limit) || limit < 1 || limit > 500) throw new Error('limit must be an integer from 1 through 500');",
                  "pm.collectionVariables.unset('cursor');",
                  "pm.collectionVariables.unset('syncWatermark');",
                  "pm.collectionVariables.unset('contactId');"
                ]
              }
            },
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 contact page', () => pm.response.to.have.status(200));",
                  "if (pm.response.code === 200) {",
                  "  const body = pm.response.json();",
                  "  const watermarkValid = typeof body.sync?.watermark === 'string' && /^\\d{4}-\\d{2}-\\d{2}T.*Z$/.test(body.sync.watermark);",
                  "  const cursorValid = body.page?.nextCursor === null || typeof body.page?.nextCursor === 'string';",
                  "  const paginationValid = typeof body.page?.hasMore === 'boolean' && cursorValid && body.page.hasMore === Boolean(body.page.nextCursor);",
                  "  const shapeValid = Array.isArray(body.items) && watermarkValid && paginationValid;",
                  "  const headersValid = pm.response.headers.get('Cache-Control') === 'private, no-store' && /^[0-9a-f-]{36}$/i.test(pm.response.headers.get('X-Request-Id') || '');",
                  "  pm.test('Response shape', () => pm.expect(shapeValid).to.be.true);",
                  "  pm.test('Safe response headers', () => pm.expect(headersValid).to.be.true);",
                  "  if (!shapeValid || !headersValid) throw new Error('Invalid contact page; synchronization state was not changed.');",
                  "  pm.collectionVariables.set('syncWatermark', body.sync.watermark);",
                  "  if (body.items.length) pm.collectionVariables.set('contactId', body.items[0].id);",
                  "  if (body.page.nextCursor) {",
                  "    pm.collectionVariables.set('cursor', body.page.nextCursor);",
                  "  } else {",
                  "    pm.collectionVariables.unset('cursor');",
                  "    pm.collectionVariables.set('watermark', body.sync.watermark);",
                  "  }",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "02 - Continue current page chain",
          "request": {
            "method": "GET",
            "header": [{ "key": "Accept", "value": "application/json" }],
            "description": "Send the opaque cursor by itself. Repeat this request while cursor remains populated. The final page commits the watermark.",
            "url": {
              "raw": "{{baseUrl}}/v1/contacts?cursor={{cursor}}",
              "host": ["{{baseUrl}}"],
              "path": ["v1", "contacts"],
              "query": [{ "key": "cursor", "value": "{{cursor}}" }]
            }
          },
          "event": [
            {
              "listen": "prerequest",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "if (!pm.collectionVariables.get('cursor')) throw new Error('No cursor is available. Start a full or incremental sync first.');"
                ]
              }
            },
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 continuation page', () => pm.response.to.have.status(200));",
                  "if (pm.response.code === 200) {",
                  "  const body = pm.response.json();",
                  "  const watermarkStable = body.sync?.watermark === pm.collectionVariables.get('syncWatermark');",
                  "  const cursorValid = body.page?.nextCursor === null || typeof body.page?.nextCursor === 'string';",
                  "  const paginationValid = Array.isArray(body.items) && typeof body.page?.hasMore === 'boolean' && cursorValid && body.page.hasMore === Boolean(body.page.nextCursor);",
                  "  pm.test('Snapshot watermark remains stable', () => pm.expect(watermarkStable).to.be.true);",
                  "  pm.test('Pagination shape', () => pm.expect(paginationValid).to.be.true);",
                  "  if (!watermarkStable || !paginationValid) throw new Error('Invalid continuation page; synchronization state was not changed.');",
                  "  if (body.page.nextCursor) {",
                  "    pm.collectionVariables.set('cursor', body.page.nextCursor);",
                  "  } else {",
                  "    pm.collectionVariables.unset('cursor');",
                  "    pm.collectionVariables.set('watermark', body.sync.watermark);",
                  "  }",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "03 - Get captured contact",
          "request": {
            "method": "GET",
            "header": [{ "key": "Accept", "value": "application/json" }],
            "description": "Reads the first contact captured by request 01. You can replace contactId with another exported UUID.",
            "url": {
              "raw": "{{baseUrl}}/v1/contacts/{{contactId}}",
              "host": ["{{baseUrl}}"],
              "path": ["v1", "contacts", "{{contactId}}"]
            }
          },
          "event": [
            {
              "listen": "prerequest",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const id = pm.collectionVariables.get('contactId') || '';",
                  "if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) throw new Error('Run request 01 first or set contactId to an exported UUID v4.');"
                ]
              }
            },
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 contact', () => pm.response.to.have.status(200));",
                  "if (pm.response.code === 200) {",
                  "  const body = pm.response.json();",
                  "  pm.expect(body.id).to.eql(pm.collectionVariables.get('contactId'));",
                  "  pm.expect(body.contactType).to.be.oneOf(['lead', 'member']);",
                  "  pm.expect(body.version).to.be.a('number').and.above(0);",
                  "}"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Incremental synchronization",
      "item": [
        {
          "name": "04 - Get changes after saved watermark",
          "request": {
            "method": "GET",
            "header": [{ "key": "Accept", "value": "application/json" }],
            "description": "Starts the next synchronization from the last fully committed watermark. Continue with request 02 if a cursor is returned.",
            "url": {
              "raw": "{{baseUrl}}/v1/contacts?updatedAfter={{watermark}}&limit={{limit}}",
              "host": ["{{baseUrl}}"],
              "path": ["v1", "contacts"],
              "query": [
                { "key": "updatedAfter", "value": "{{watermark}}" },
                { "key": "limit", "value": "{{limit}}" }
              ]
            }
          },
          "event": [
            {
              "listen": "prerequest",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const watermark = pm.collectionVariables.get('watermark') || '';",
                  "if (!/^\\d{4}-\\d{2}-\\d{2}T.*Z$/.test(watermark) || Number.isNaN(Date.parse(watermark))) throw new Error('Complete the full page chain before starting an incremental sync.');",
                  "const limit = Number(pm.collectionVariables.get('limit'));",
                  "if (!Number.isInteger(limit) || limit < 1 || limit > 500) throw new Error('limit must be an integer from 1 through 500');",
                  "pm.collectionVariables.unset('cursor');",
                  "pm.collectionVariables.unset('syncWatermark');"
                ]
              }
            },
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 incremental page', () => pm.response.to.have.status(200));",
                  "if (pm.response.code === 200) {",
                  "  const body = pm.response.json();",
                  "  const watermarkValid = typeof body.sync?.watermark === 'string' && /^\\d{4}-\\d{2}-\\d{2}T.*Z$/.test(body.sync.watermark);",
                  "  const cursorValid = body.page?.nextCursor === null || typeof body.page?.nextCursor === 'string';",
                  "  const paginationValid = typeof body.page?.hasMore === 'boolean' && cursorValid && body.page.hasMore === Boolean(body.page.nextCursor);",
                  "  const shapeValid = Array.isArray(body.items) && watermarkValid && paginationValid;",
                  "  pm.test('Response shape', () => pm.expect(shapeValid).to.be.true);",
                  "  if (!shapeValid) throw new Error('Invalid incremental page; synchronization state was not changed.');",
                  "  pm.collectionVariables.set('syncWatermark', body.sync.watermark);",
                  "  if (body.page.nextCursor) {",
                  "    pm.collectionVariables.set('cursor', body.page.nextCursor);",
                  "  } else {",
                  "    pm.collectionVariables.unset('cursor');",
                  "    pm.collectionVariables.set('watermark', body.sync.watermark);",
                  "  }",
                  "}"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Filters",
      "description": "Independent read-only examples. These requests do not change synchronization variables.",
      "item": [
        {
          "name": "05 - List leads",
          "request": {
            "method": "GET",
            "header": [{ "key": "Accept", "value": "application/json" }],
            "url": {
              "raw": "{{baseUrl}}/v1/contacts?contactType=lead&limit={{limit}}",
              "host": ["{{baseUrl}}"],
              "path": ["v1", "contacts"],
              "query": [
                { "key": "contactType", "value": "lead" },
                { "key": "limit", "value": "{{limit}}" }
              ]
            }
          },
          "event": [{
            "listen": "test",
            "script": {
              "type": "text/javascript",
              "exec": [
                "pm.test('200 lead page', () => pm.response.to.have.status(200));",
                "if (pm.response.code === 200) pm.expect(pm.response.json().items.every(item => item.contactType === 'lead')).to.be.true;"
              ]
            }
          }]
        },
        {
          "name": "06 - List members",
          "request": {
            "method": "GET",
            "header": [{ "key": "Accept", "value": "application/json" }],
            "url": {
              "raw": "{{baseUrl}}/v1/contacts?contactType=member&limit={{limit}}",
              "host": ["{{baseUrl}}"],
              "path": ["v1", "contacts"],
              "query": [
                { "key": "contactType", "value": "member" },
                { "key": "limit", "value": "{{limit}}" }
              ]
            }
          },
          "event": [{
            "listen": "test",
            "script": {
              "type": "text/javascript",
              "exec": [
                "pm.test('200 member page', () => pm.response.to.have.status(200));",
                "if (pm.response.code === 200) pm.expect(pm.response.json().items.every(item => item.contactType === 'member')).to.be.true;"
              ]
            }
          }]
        }
      ]
    },
    {
      "name": "Safe error examples",
      "description": "Read-only negative examples that demonstrate authentication and cursor validation.",
      "item": [
        {
          "name": "07 - Missing API key returns 401",
          "request": {
            "auth": { "type": "noauth" },
            "method": "GET",
            "header": [{ "key": "Accept", "value": "application/json" }],
            "url": {
              "raw": "{{baseUrl}}/v1/contacts",
              "host": ["{{baseUrl}}"],
              "path": ["v1", "contacts"]
            }
          },
          "event": [{
            "listen": "test",
            "script": {
              "type": "text/javascript",
              "exec": [
                "pm.test('401 unauthorized', () => pm.response.to.have.status(401));",
                "pm.test('Stable error code', () => pm.expect(pm.response.json().error.code).to.eql('unauthorized'));",
                "pm.expect(pm.response.headers.get('WWW-Authenticate')).to.include('Bearer');"
              ]
            }
          }]
        },
        {
          "name": "08 - Cursor combined with limit returns 400",
          "request": {
            "method": "GET",
            "header": [{ "key": "Accept", "value": "application/json" }],
            "description": "A cursor must be sent as the only query parameter. The API rejects this request before parsing the deliberately invalid cursor.",
            "url": {
              "raw": "{{baseUrl}}/v1/contacts?cursor=invalid&limit=2",
              "host": ["{{baseUrl}}"],
              "path": ["v1", "contacts"],
              "query": [
                { "key": "cursor", "value": "invalid" },
                { "key": "limit", "value": "2" }
              ]
            }
          },
          "event": [{
            "listen": "test",
            "script": {
              "type": "text/javascript",
              "exec": [
                "pm.test('400 invalid request', () => pm.response.to.have.status(400));",
                "pm.expect(pm.response.json().error.code).to.eql('invalid_request');"
              ]
            }
          }]
        }
      ]
    }
  ]
}
