{
  "info": {
    "_postman_id": "f0a9e1d2-1a2b-4c3d-9e4f-000000000001",
    "name": "Forge Logger Ingest API (v1)",
    "description": "Public ingest API used by Forge Logger game plugins (Godot and future SDKs) to send sessions, events, attachments and bug reports.\n\nBase URL: https://ingest.forgelogger.dev\nAuth: Authorization: Bearer flg_... (project-scoped plugin token)\n\nSetup:\n1. Import the matching environment (forge-logger-ingest.postman_environment.json) and select it.\n2. Set `loggerToken` to your plugin token (from Forge Logger dashboard -> project -> tokens).\n3. Run the requests in order (00 -> 07). Each stores the ids the next one needs.",
    "version": "1.0.0",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [{ "key": "token", "value": "{{loggerToken}}", "type": "string" }]
  },
  "variable": [
    { "key": "baseUrl", "value": "https://ingest.forgelogger.dev" },
    { "key": "loggerToken", "value": "" },
    { "key": "sessionId", "value": "" },
    { "key": "buildId", "value": "" },
    { "key": "uploadId", "value": "" },
    { "key": "uploadUrl", "value": "" },
    { "key": "publicUrl", "value": "" },
    { "key": "eventId", "value": "" },
    { "key": "reportId", "value": "" },
    { "key": "clientRequestId", "value": "" }
  ],
  "item": [
    {
      "name": "00. Health check",
      "request": {
        "auth": { "type": "noauth" },
        "method": "GET",
        "header": [],
        "url": {
          "raw": "{{baseUrl}}/health",
          "host": ["{{baseUrl}}"],
          "path": ["health"]
        },
        "description": "Unauthenticated liveness probe (not versioned)."
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('health is 200', function () {",
              "  pm.response.to.have.status(200);",
              "});"
            ]
          }
        }
      ]
    },
    {
      "name": "01. Start session",
      "request": {
        "method": "POST",
        "header": [{ "key": "Content-Type", "value": "application/json" }],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"build\": {\n    \"version\": \"1.0.0\",\n    \"platform\": \"windows\",\n    \"channel\": \"prod\",\n    \"gitCommit\": \"abc1234\"\n  },\n  \"player\": {\n    \"externalId\": \"player-001\",\n    \"deviceId\": \"device-abc\"\n  },\n  \"metadata\": {\n    \"engineVersion\": \"Godot 4.3\",\n    \"locale\": \"en\"\n  }\n}",
          "options": { "raw": { "language": "json" } }
        },
        "url": {
          "raw": "{{baseUrl}}/v1/ingest/sessions",
          "host": ["{{baseUrl}}"],
          "path": ["v1", "ingest", "sessions"]
        },
        "description": "Opens a game session. build.version and build.platform are required and non-empty. channel: dev|qa|alpha|beta|staging|prod."
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('returns sessionId and buildId', function () {",
              "  pm.response.to.have.status(201);",
              "  var b = pm.response.json();",
              "  pm.expect(b.sessionId).to.be.a('string');",
              "  pm.expect(b.buildId).to.be.a('string');",
              "  pm.collectionVariables.set('sessionId', b.sessionId);",
              "  pm.collectionVariables.set('buildId', b.buildId);",
              "});"
            ]
          }
        }
      ]
    },
    {
      "name": "02. Post a single event",
      "request": {
        "method": "POST",
        "header": [{ "key": "Content-Type", "value": "application/json" }],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"sessionId\": \"{{sessionId}}\",\n  \"eventType\": \"level_started\",\n  \"payload\": { \"level\": \"forest_02\", \"difficulty\": \"normal\" }\n}",
          "options": { "raw": { "language": "json" } }
        },
        "url": {
          "raw": "{{baseUrl}}/v1/ingest/events",
          "host": ["{{baseUrl}}"],
          "path": ["v1", "ingest", "events"]
        },
        "description": "A single event may be posted as one JSON object; the server normalises it to a batch. The response is always an array of stored events."
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('stores the event', function () {",
              "  pm.response.to.have.status(201);",
              "  var b = pm.response.json();",
              "  pm.expect(b).to.be.an('array');",
              "  if (b.length && b[0].id) { pm.collectionVariables.set('eventId', b[0].id); }",
              "});"
            ]
          }
        }
      ]
    },
    {
      "name": "03. Post a batch of events",
      "request": {
        "method": "POST",
        "header": [{ "key": "Content-Type", "value": "application/json" }],
        "body": {
          "mode": "raw",
          "raw": "[\n  {\n    \"sessionId\": \"{{sessionId}}\",\n    \"eventType\": \"enemy_killed\",\n    \"payload\": { \"enemy\": \"goblin\", \"weapon\": \"sword\" }\n  },\n  {\n    \"sessionId\": \"{{sessionId}}\",\n    \"eventType\": \"item_collected\",\n    \"payload\": { \"item\": \"health_potion\", \"quantity\": 2 }\n  }\n]",
          "options": { "raw": { "language": "json" } }
        },
        "url": {
          "raw": "{{baseUrl}}/v1/ingest/events",
          "host": ["{{baseUrl}}"],
          "path": ["v1", "ingest", "events"]
        },
        "description": "Events can be batched (JSON array). Max 500 per request (else 400). Over-quota events are stored with overQuota: true."
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('accepts the batch', function () {",
              "  pm.response.to.have.status(201);",
              "  pm.expect(pm.response.json()).to.be.an('array');",
              "});"
            ]
          }
        }
      ]
    },
    {
      "name": "04. Create an upload target",
      "request": {
        "method": "POST",
        "header": [{ "key": "Content-Type", "value": "application/json" }],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"sessionId\": \"{{sessionId}}\",\n  \"attachmentType\": \"log_bundle\",\n  \"fileName\": \"godot.log\",\n  \"mimeType\": \"text/plain\",\n  \"sizeBytes\": 42\n}",
          "options": { "raw": { "language": "json" } }
        },
        "url": {
          "raw": "{{baseUrl}}/v1/ingest/uploads",
          "host": ["{{baseUrl}}"],
          "path": ["v1", "ingest", "uploads"]
        },
        "description": "Reserves a storage slot and returns a signed uploadUrl (PUT the bytes to it in request 05) plus an uploadId to reference from a report. attachmentType: screenshot|video|save_state|log_bundle|other. requiredHeaders lists headers you must repeat on the PUT."
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('returns an upload target', function () {",
              "  pm.response.to.have.status(201);",
              "  var b = pm.response.json();",
              "  pm.expect(b.uploadUrl).to.be.a('string');",
              "  pm.collectionVariables.set('uploadId', b.uploadId);",
              "  pm.collectionVariables.set('uploadUrl', b.uploadUrl);",
              "  if (b.publicUrl) { pm.collectionVariables.set('publicUrl', b.publicUrl); }",
              "});"
            ]
          }
        }
      ]
    },
    {
      "name": "05. Upload the file bytes (signed URL)",
      "request": {
        "auth": { "type": "noauth" },
        "method": "PUT",
        "header": [{ "key": "Content-Type", "value": "text/plain" }],
        "body": { "mode": "raw", "raw": "forge logger test log bundle" },
        "url": {
          "raw": "{{uploadUrl}}",
          "host": ["{{uploadUrl}}"]
        },
        "description": "Uploads the raw bytes straight to the storage provider using the signed uploadUrl from request 04. Goes to the storage host, NOT the ingest API, so it carries NO Authorization header. Send the Content-Type you declared as mimeType plus any requiredHeaders from the previous response. Self-hosted: replace 0.0.0.0 with 127.0.0.1."
      }
    },
    {
      "name": "06. Submit a bug report",
      "request": {
        "method": "POST",
        "header": [{ "key": "Content-Type", "value": "application/json" }],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"sessionId\": \"{{sessionId}}\",\n  \"clientRequestId\": \"{{clientRequestId}}\",\n  \"report\": {\n    \"title\": \"Player stuck in wall\",\n    \"description\": \"After entering forest_02 the player clipped into the rock face.\",\n    \"severity\": \"medium\",\n    \"reporterType\": \"player\"\n  },\n  \"context\": {\n    \"sceneName\": \"forest_02\",\n    \"extra\": { \"tags\": [\"collision\", \"inventory\"] }\n  },\n  \"attachments\": [\n    {\n      \"uploadId\": \"{{uploadId}}\",\n      \"attachmentType\": \"log_bundle\",\n      \"fileName\": \"godot.log\",\n      \"mimeType\": \"text/plain\"\n    }\n  ],\n  \"eventIds\": [\"{{eventId}}\"]\n}",
          "options": { "raw": { "language": "json" } }
        },
        "url": {
          "raw": "{{baseUrl}}/v1/ingest/reports",
          "host": ["{{baseUrl}}"],
          "path": ["v1", "ingest", "reports"]
        },
        "description": "Submits the bug report, linking the uploaded attachment and event captured earlier. clientRequestId is an idempotency key. Running standalone? Drop the attachments and eventIds arrays; only sessionId and report.title are required."
      },
      "event": [
        {
          "listen": "prerequest",
          "script": {
            "type": "text/javascript",
            "exec": [
              "// Generate a stable idempotency key, reused by request 07.",
              "if (!pm.collectionVariables.get('clientRequestId')) {",
              "  pm.collectionVariables.set('clientRequestId', pm.variables.replaceIn('{{$guid}}'));",
              "}"
            ]
          }
        },
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('returns a reportId', function () {",
              "  pm.response.to.have.status(201);",
              "  var b = pm.response.json();",
              "  pm.expect(b.reportId).to.be.a('string');",
              "  pm.collectionVariables.set('reportId', b.reportId);",
              "});"
            ]
          }
        }
      ]
    },
    {
      "name": "07. Resubmit report (idempotent)",
      "request": {
        "method": "POST",
        "header": [{ "key": "Content-Type", "value": "application/json" }],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"sessionId\": \"{{sessionId}}\",\n  \"clientRequestId\": \"{{clientRequestId}}\",\n  \"report\": {\n    \"title\": \"Player stuck in wall\",\n    \"description\": \"After entering forest_02 the player clipped into the rock face.\",\n    \"severity\": \"medium\",\n    \"reporterType\": \"player\"\n  }\n}",
          "options": { "raw": { "language": "json" } }
        },
        "url": {
          "raw": "{{baseUrl}}/v1/ingest/reports",
          "host": ["{{baseUrl}}"],
          "path": ["v1", "ingest", "reports"]
        },
        "description": "Resends request 06 with the same clientRequestId. The server does not create a new report; it returns the original with idempotent: true and the same reportId. This is how a plugin safely retries after a network error."
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('same clientRequestId returns the original report', function () {",
              "  pm.response.to.have.status(201);",
              "  var b = pm.response.json();",
              "  pm.expect(b.idempotent).to.eql(true);",
              "  pm.expect(b.reportId).to.eql(pm.collectionVariables.get('reportId'));",
              "});"
            ]
          }
        }
      ]
    },
    {
      "name": "08. Unauthorized (no token)",
      "request": {
        "auth": { "type": "noauth" },
        "method": "POST",
        "header": [{ "key": "Content-Type", "value": "application/json" }],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"build\": { \"version\": \"1.0.0\", \"platform\": \"windows\" }\n}",
          "options": { "raw": { "language": "json" } }
        },
        "url": {
          "raw": "{{baseUrl}}/v1/ingest/sessions",
          "host": ["{{baseUrl}}"],
          "path": ["v1", "ingest", "sessions"]
        },
        "description": "Sanity check that the API rejects unauthenticated calls. Sends no Authorization header (auth: noauth) and must return 401. An invalid or revoked token returns 403 instead."
      },
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "pm.test('rejects request with no Bearer token', function () {",
              "  pm.response.to.have.status(401);",
              "});"
            ]
          }
        }
      ]
    }
  ]
}
