{
  "info": {
    "name": "Nibble Partner Grant API",
    "description": "Verifies the three Nibble Partner Grant API endpoints against production (api.nibble-app.io — the partner API host; consumer redemption links stay on claim.nibble-app.io).\n\nSetup: import nibble-production.postman_environment.json alongside this collection, select the environment, and paste your partner API key (nib_test_… / nib_live_…) into its apiKey variable. Then set the campaignId collection variable to your campaign's id. Keys are scoped to one campaign — ask your Nibble contact for an API key and campaign id. Start with a test-tier key (nib_test_…): it exercises the full contract without touching a live campaign's budget.\n\nRun the whole collection top-to-bottom (Runner) — the Happy path folder chains a fresh user through issue → replay → status reads, and the Error cases folder asserts the documented error codes. Every request carries pm.test assertions, so the Runner reports pass/fail per contract rule.\n\nFull docs: https://nibble-app.io/api.html",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{apiKey}}",
        "type": "string"
      }
    ]
  },
  "variable": [
    {
      "key": "campaignId",
      "value": "cmp_your_campaign_id"
    },
    {
      "key": "userId",
      "value": ""
    },
    {
      "key": "grantRef",
      "value": ""
    },
    {
      "key": "claimId",
      "value": ""
    }
  ],
  "item": [
    {
      "name": "1 · Happy path",
      "item": [
        {
          "name": "Issue grant (fresh user + receipt)",
          "event": [
            {
              "listen": "prerequest",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "// Fresh person + qualifying event per run, reused by later requests.",
                  "const stamp = Date.now();",
                  "pm.collectionVariables.set('userId', 'pm_user_' + stamp);",
                  "pm.collectionVariables.set('grantRef', 'receipt_PM_' + stamp);"
                ]
              }
            },
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('201 Created', () => pm.response.to.have.status(201));",
                  "const b = pm.response.json();",
                  "pm.test('claimId is a rid_…', () => pm.expect(b.claimId).to.match(/^rid_/));",
                  "pm.test('redemptionUrl points at the claim page', () =>",
                  "  pm.expect(b.redemptionUrl).to.include('/c/' + pm.variables.get('campaignId') + '?claim=' + b.claimId));",
                  "pm.test('redemptionUrl is a public https URL', () =>",
                  "  pm.expect(b.redemptionUrl).to.match(/^https:\\/\\//).and.to.not.include('localhost'));",
                  "pm.test('state valid, not a replay', () => {",
                  "  pm.expect(b.state).to.eql('valid');",
                  "  pm.expect(b.alreadyGranted).to.eql(false);",
                  "});",
                  "pm.test('expiresAt is an ISO date', () => pm.expect(new Date(b.expiresAt).toString()).to.not.eql('Invalid Date'));",
                  "pm.collectionVariables.set('claimId', b.claimId);"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"externalUserId\": \"{{userId}}\",\n  \"email\": \"{{userId}}@example.com\",\n  \"displayName\": \"Postman Test\",\n  \"eligibilityRef\": \"{{grantRef}}\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/api/v1/campaigns/{{campaignId}}/grants",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "v1",
                "campaigns",
                "{{campaignId}}",
                "grants"
              ]
            }
          }
        },
        {
          "name": "Replay same receipt (idempotency)",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK (not 201)', () => pm.response.to.have.status(200));",
                  "const b = pm.response.json();",
                  "pm.test('same claim returned — no double issue', () =>",
                  "  pm.expect(b.claimId).to.eql(pm.collectionVariables.get('claimId')));",
                  "pm.test('alreadyGranted: true', () => pm.expect(b.alreadyGranted).to.eql(true));"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"externalUserId\": \"{{userId}}\",\n  \"email\": \"{{userId}}@example.com\",\n  \"eligibilityRef\": \"{{grantRef}}\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/api/v1/campaigns/{{campaignId}}/grants",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "v1",
                "campaigns",
                "{{campaignId}}",
                "grants"
              ]
            }
          }
        },
        {
          "name": "Campaign meta",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const b = pm.response.json();",
                  "pm.test('identity fields present', () => {",
                  "  pm.expect(b.id).to.eql(pm.variables.get('campaignId'));",
                  "  pm.expect(b.name).to.be.a('string');",
                  "  pm.expect(b.rewardLabel).to.be.a('string');",
                  "});",
                  "pm.test('acceptingGrants is a boolean', () => pm.expect(b.acceptingGrants).to.be.a('boolean'));",
                  "pm.test('rewardsRemaining is a non-negative count', () => {",
                  "  pm.expect(b.rewardsRemaining).to.be.a('number');",
                  "  pm.expect(b.rewardsRemaining).to.be.at.least(0);",
                  "});",
                  "pm.test('perUserLimit >= 1', () => pm.expect(b.perUserLimit).to.be.at.least(1));"
                ]
              }
            }
          ],
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/v1/campaigns/{{campaignId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "v1",
                "campaigns",
                "{{campaignId}}"
              ]
            }
          }
        },
        {
          "name": "Person grant status",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK', () => pm.response.to.have.status(200));",
                  "const b = pm.response.json();",
                  "pm.test('reports the user we granted to', () =>",
                  "  pm.expect(b.externalUserId).to.eql(pm.collectionVariables.get('userId')));",
                  "pm.test('granted >= 1 with matching claim', () => {",
                  "  pm.expect(b.granted).to.be.at.least(1);",
                  "  const ids = b.claims.map(c => c.claimId);",
                  "  pm.expect(ids).to.include(pm.collectionVariables.get('claimId'));",
                  "});",
                  "pm.test('remaining + granted = perUserLimit', () =>",
                  "  pm.expect(b.granted + b.remaining).to.eql(b.perUserLimit));",
                  "pm.test('claims carry state + redemptionUrl + grantRef', () => {",
                  "  const c = b.claims[0];",
                  "  pm.expect(c.state).to.be.a('string');",
                  "  pm.expect(c.redemptionUrl).to.match(/^https:\\/\\//);",
                  "  pm.expect(c.grantRef).to.be.a('string');",
                  "});"
                ]
              }
            }
          ],
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/v1/campaigns/{{campaignId}}/grants/{{userId}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "v1",
                "campaigns",
                "{{campaignId}}",
                "grants",
                "{{userId}}"
              ]
            }
          }
        },
        {
          "name": "Person status — never granted",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('200 OK — zero grants is not an error', () => pm.response.to.have.status(200));",
                  "const b = pm.response.json();",
                  "pm.test('granted 0, empty claims, not at limit', () => {",
                  "  pm.expect(b.granted).to.eql(0);",
                  "  pm.expect(b.claims).to.eql([]);",
                  "  pm.expect(b.atLimit).to.eql(false);",
                  "});"
                ]
              }
            }
          ],
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/v1/campaigns/{{campaignId}}/grants/pm_nobody_never_granted",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "v1",
                "campaigns",
                "{{campaignId}}",
                "grants",
                "pm_nobody_never_granted"
              ]
            }
          }
        }
      ]
    },
    {
      "name": "2 · Error cases",
      "item": [
        {
          "name": "No API key → 401 unauthorized",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('401', () => pm.response.to.have.status(401));",
                  "pm.test(\"error code 'unauthorized'\", () => pm.expect(pm.response.json().error).to.eql('unauthorized'));"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"externalUserId\": \"x\",\n  \"email\": \"x@example.com\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/api/v1/campaigns/{{campaignId}}/grants",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "v1",
                "campaigns",
                "{{campaignId}}",
                "grants"
              ]
            },
            "auth": {
              "type": "noauth"
            }
          }
        },
        {
          "name": "Key used on another campaign → 403 campaign_not_authorized",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('403', () => pm.response.to.have.status(403));",
                  "pm.test(\"error code 'campaign_not_authorized'\", () =>",
                  "  pm.expect(pm.response.json().error).to.eql('campaign_not_authorized'));",
                  "// NOTE: unknown campaign ids return this same code by design —",
                  "// keys are campaign-scoped and ids are not enumerable."
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"externalUserId\": \"x\",\n  \"email\": \"x@example.com\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/api/v1/campaigns/cmp_some_other_campaign/grants",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "v1",
                "campaigns",
                "cmp_some_other_campaign",
                "grants"
              ]
            }
          }
        },
        {
          "name": "Missing eligibilityRef (perUserLimit > 1) → 400 grant_ref_required",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('400', () => pm.response.to.have.status(400));",
                  "pm.test(\"error code 'grant_ref_required'\", () =>",
                  "  pm.expect(pm.response.json().error).to.eql('grant_ref_required'));"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"externalUserId\": \"pm_no_ref_{{$timestamp}}\",\n  \"email\": \"pm-no-ref@example.com\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/api/v1/campaigns/{{campaignId}}/grants",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "v1",
                "campaigns",
                "{{campaignId}}",
                "grants"
              ]
            }
          }
        },
        {
          "name": "Plus-alias email → 409 email_rejected",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('409', () => pm.response.to.have.status(409));",
                  "const b = pm.response.json();",
                  "pm.test(\"error 'email_rejected', reason 'plus-alias'\", () => {",
                  "  pm.expect(b.error).to.eql('email_rejected');",
                  "  pm.expect(b.reason).to.eql('plus-alias');",
                  "});"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"externalUserId\": \"pm_alias_{{$timestamp}}\",\n  \"email\": \"someone+alias@gmail.com\",\n  \"eligibilityRef\": \"receipt_alias_{{$timestamp}}\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/api/v1/campaigns/{{campaignId}}/grants",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "v1",
                "campaigns",
                "{{campaignId}}",
                "grants"
              ]
            }
          }
        },
        {
          "name": "Missing externalUserId → 400",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('400', () => pm.response.to.have.status(400));",
                  "pm.test(\"error code 'missing_external_user_id'\", () =>",
                  "  pm.expect(pm.response.json().error).to.eql('missing_external_user_id'));"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"x@example.com\"\n}"
            },
            "url": {
              "raw": "{{baseUrl}}/api/v1/campaigns/{{campaignId}}/grants",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "v1",
                "campaigns",
                "{{campaignId}}",
                "grants"
              ]
            }
          }
        },
        {
          "name": "GET method on /grants → 405",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('405 with Allow: POST', () => {",
                  "  pm.response.to.have.status(405);",
                  "  pm.expect(pm.response.headers.get('Allow')).to.eql('POST');",
                  "});"
                ]
              }
            }
          ],
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/api/v1/campaigns/{{campaignId}}/grants",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "api",
                "v1",
                "campaigns",
                "{{campaignId}}",
                "grants"
              ]
            }
          }
        }
      ]
    }
  ]
}
