{
  "info": {
    "_postman_id": "00000000-0000-4000-8000-000000000101",
    "name": "Doqlo Bulk Fill Public API",
    "description": {
      "content": "Use this collection to run the Doqlo Bulk Fill Public API locally in Postman.\n\nDoqlo Bulk Fill Public API executes `.doqlo` packages exported from the web Bulk Fill editor.\n\nBefore you run the requests:\n- create the layout and mapping in the web app first\n- export the `.doqlo` package from the web app\n- create a Bulk Fill API key from your account page\n- set `DOQLO_API_KEY` in the imported Postman environment\n- choose local files for `pdf` and `doqlo_file`\n- submit a job\n- poll the job if needed\n- download the ZIP result when the job is completed\n\nFree plan users can run the Public API within Free plan limits. Paid plans support larger PDFs and higher monthly volume.",
      "type": "text/markdown"
    },
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{DOQLO_API_KEY}}",
        "type": "string"
      }
    ]
  },
  "item": [
    {
      "name": "Create export job with JSON rows",
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "let data = null;",
              "try {",
              "  data = pm.response.json();",
              "} catch (error) {",
              "  data = null;",
              "}",
              "if (data && typeof data.job_id === 'string' && pm.environment && typeof pm.environment.set === 'function') {",
              "  pm.environment.set('DOQLO_JOB_ID', data.job_id);",
              "}"
            ]
          }
        }
      ],
      "request": {
        "method": "POST",
        "header": [
          {
            "key": "Idempotency-Key",
            "value": "{{DOQLO_IDEMPOTENCY_KEY}}",
            "type": "text"
          },
          {
            "key": "X-Request-Id",
            "value": "{{DOQLO_REQUEST_ID}}",
            "type": "text"
          }
        ],
        "body": {
          "mode": "formdata",
          "formdata": [
            {
              "key": "pdf",
              "type": "file",
              "src": null,
              "description": "Choose `source.pdf` locally after import. Postman collections do not bundle local files."
            },
            {
              "key": "doqlo_file",
              "type": "file",
              "src": null,
              "description": "Choose `package.doqlo` locally after import. Postman collections do not bundle local files."
            },
            {
              "key": "max_failed_row_percent",
              "value": "5",
              "type": "text"
            },
            {
              "key": "rows_json",
              "value": "[{\"column_0\":\"Alice Nguyen\",\"column_1\":\"INV-1001\"},{\"column_0\":\"Marco Silva\",\"column_1\":\"INV-1002\"}]",
              "type": "text"
            },
            {
              "key": "filename_columns",
              "value": "[\"column_0\",\"column_1\"]",
              "type": "text"
            },
            {
              "key": "flatten_forms",
              "value": "false",
              "type": "text"
            },
            {
              "key": "include_stickers",
              "value": "false",
              "type": "text"
            }
          ]
        },
        "url": "{{DOQLO_BASE_URL}}/v1/bulkfill/export-jobs",
        "description": {
          "content": "Create a minimal response-mode export job.\n\n- Choose local `pdf` and `doqlo_file` files after import because Postman collections do not bundle local files.\n- `rows_json` uses positional keys such as `column_0` and `column_1`.\n- If the response includes a top-level `job_id`, this request saves it to `DOQLO_JOB_ID` in the active environment.\n- Use a new `DOQLO_IDEMPOTENCY_KEY` value for each new logical create request. Change it before switching between JSON and CSV create examples.",
          "type": "text/markdown"
        }
      },
      "response": [
        {
          "name": "200 Completed export job",
          "originalRequest": {
            "method": "POST",
            "header": [
              {
                "key": "Idempotency-Key",
                "value": "{{DOQLO_IDEMPOTENCY_KEY}}",
                "type": "text"
              },
              {
                "key": "X-Request-Id",
                "value": "{{DOQLO_REQUEST_ID}}",
                "type": "text"
              }
            ],
            "url": "{{DOQLO_BASE_URL}}/v1/bulkfill/export-jobs"
          },
          "status": "OK",
          "code": 200,
          "_postman_previewlanguage": "json",
          "header": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "cookie": [],
          "body": "{\n  \"job_id\": \"example-job-id\",\n  \"status\": \"completed\",\n  \"created_at\": \"2026-04-24T15:18:30.577005Z\",\n  \"started_at\": \"2026-04-24T15:18:30.592607Z\",\n  \"completed_at\": \"2026-04-24T15:18:32.836613Z\",\n  \"result\": {\n    \"delivery_mode\": \"direct\",\n    \"download_url\": \"/v1/bulkfill/export-jobs/example-job-id/download\",\n    \"expires_at\": \"2026-04-24T21:18:32.824000Z\",\n    \"file_size_bytes\": 2921485\n  }\n}"
        }
      ]
    },
    {
      "name": "Create export job with CSV file",
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "let data = null;",
              "try {",
              "  data = pm.response.json();",
              "} catch (error) {",
              "  data = null;",
              "}",
              "if (data && typeof data.job_id === 'string' && pm.environment && typeof pm.environment.set === 'function') {",
              "  pm.environment.set('DOQLO_JOB_ID', data.job_id);",
              "}"
            ]
          }
        }
      ],
      "request": {
        "method": "POST",
        "header": [
          {
            "key": "Idempotency-Key",
            "value": "{{DOQLO_IDEMPOTENCY_KEY}}",
            "type": "text"
          },
          {
            "key": "X-Request-Id",
            "value": "{{DOQLO_REQUEST_ID}}",
            "type": "text"
          }
        ],
        "body": {
          "mode": "formdata",
          "formdata": [
            {
              "key": "pdf",
              "type": "file",
              "src": null,
              "description": "Choose `source.pdf` locally after import. Postman collections do not bundle local files."
            },
            {
              "key": "doqlo_file",
              "type": "file",
              "src": null,
              "description": "Choose `package.doqlo` locally after import. Postman collections do not bundle local files."
            },
            {
              "key": "csv_file",
              "type": "file",
              "src": null,
              "description": "Choose `rows.csv` locally after import. The public CSV contract is positional and does not auto-detect headers."
            },
            {
              "key": "max_failed_row_percent",
              "value": "5",
              "type": "text"
            },
            {
              "key": "filename_columns",
              "value": "[\"column_0\",\"column_1\"]",
              "type": "text"
            },
            {
              "key": "flatten_forms",
              "value": "false",
              "type": "text"
            },
            {
              "key": "include_stickers",
              "value": "false",
              "type": "text"
            }
          ]
        },
        "url": "{{DOQLO_BASE_URL}}/v1/bulkfill/export-jobs",
        "description": {
          "content": "Create a response-mode export job from a CSV upload.\n\n- Choose local `pdf`, `doqlo_file`, and `csv_file` files after import because Postman collections do not bundle local files.\n- Provide exactly one of `rows_json` or `csv_file`.\n- The public CSV contract is positional. Keep your mapped columns aligned to `column_0`, `column_1`, and later positions.\n- If the response includes a top-level `job_id`, this request saves it to `DOQLO_JOB_ID` in the active environment.\n- Use a new `DOQLO_IDEMPOTENCY_KEY` value for each new logical create request. Change it before switching between JSON and CSV create examples.",
          "type": "text/markdown"
        }
      },
      "response": [
        {
          "name": "200 Completed export job",
          "originalRequest": {
            "method": "POST",
            "header": [
              {
                "key": "Idempotency-Key",
                "value": "{{DOQLO_IDEMPOTENCY_KEY}}",
                "type": "text"
              },
              {
                "key": "X-Request-Id",
                "value": "{{DOQLO_REQUEST_ID}}",
                "type": "text"
              }
            ],
            "url": "{{DOQLO_BASE_URL}}/v1/bulkfill/export-jobs"
          },
          "status": "OK",
          "code": 200,
          "_postman_previewlanguage": "json",
          "header": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "cookie": [],
          "body": "{\n  \"job_id\": \"example-job-id\",\n  \"status\": \"completed\",\n  \"created_at\": \"2026-04-24T15:18:30.577005Z\",\n  \"started_at\": \"2026-04-24T15:18:30.592607Z\",\n  \"completed_at\": \"2026-04-24T15:18:32.836613Z\",\n  \"result\": {\n    \"delivery_mode\": \"direct\",\n    \"download_url\": \"/v1/bulkfill/export-jobs/example-job-id/download\",\n    \"expires_at\": \"2026-04-24T21:18:32.824000Z\",\n    \"file_size_bytes\": 2921485\n  }\n}"
        }
      ]
    },
    {
      "name": "Get export job status",
      "request": {
        "method": "GET",
        "url": "{{DOQLO_BASE_URL}}/v1/bulkfill/export-jobs/{{DOQLO_JOB_ID}}",
        "description": {
          "content": "Read the current state of one export job.\n\n- Use this request if create returns `queued` or `processing`.\n- Wait until status is `completed` before downloading.\n- If status is `failed`, inspect the returned error details.",
          "type": "text/markdown"
        }
      },
      "response": [
        {
          "name": "200 Completed export job status",
          "originalRequest": {
            "method": "GET",
            "header": [],
            "url": "{{DOQLO_BASE_URL}}/v1/bulkfill/export-jobs/{{DOQLO_JOB_ID}}"
          },
          "status": "OK",
          "code": 200,
          "_postman_previewlanguage": "json",
          "header": [
            {
              "key": "Content-Type",
              "value": "application/json"
            }
          ],
          "cookie": [],
          "body": "{\n  \"job_id\": \"example-job-id\",\n  \"status\": \"completed\",\n  \"created_at\": \"2026-04-24T15:18:30.577005Z\",\n  \"started_at\": \"2026-04-24T15:18:30.592607Z\",\n  \"completed_at\": \"2026-04-24T15:18:32.836613Z\",\n  \"result\": {\n    \"delivery_mode\": \"direct\",\n    \"download_url\": \"/v1/bulkfill/export-jobs/example-job-id/download\",\n    \"expires_at\": \"2026-04-24T21:18:32.824000Z\",\n    \"file_size_bytes\": 2921485\n  }\n}"
        }
      ]
    },
    {
      "name": "Download export result",
      "event": [
        {
          "listen": "test",
          "script": {
            "type": "text/javascript",
            "exec": [
              "// Proof-of-download visualization for ZIP file",
              "const stream = pm.response.stream;",
              "const bytes = new Uint8Array(stream);",
              "const totalSize = bytes.length;",
              "",
              "// 1. Check ZIP magic signature: PK\\x03\\x04",
              "const isZip = bytes.length >= 4 &&",
              "  bytes[0] === 0x50 && bytes[1] === 0x4B &&",
              "  bytes[2] === 0x03 && bytes[3] === 0x04;",
              "",
              "const magicHex = Array.from(bytes.slice(0, 4))",
              "  .map((b) => b.toString(16).padStart(2, '0').toUpperCase())",
              "  .join(' ');",
              "",
              "// 2. Format size",
              "function formatSize(n) {",
              "  if (n < 1024) return n + ' B';",
              "  if (n < 1024 * 1024) return (n / 1024).toFixed(2) + ' KB';",
              "  return (n / (1024 * 1024)).toFixed(2) + ' MB';",
              "}",
              "",
              "// 3. Parse ZIP central directory to list files inside",
              "function parseZipFileList(buf) {",
              "  const files = [];",
              "  let eocdOffset = -1;",
              "  const maxScan = Math.min(buf.length, 65557);",
              "",
              "  for (let i = buf.length - 22; i >= buf.length - maxScan && i >= 0; i--) {",
              "    if (buf[i] === 0x50 && buf[i + 1] === 0x4B && buf[i + 2] === 0x05 && buf[i + 3] === 0x06) {",
              "      eocdOffset = i;",
              "      break;",
              "    }",
              "  }",
              "",
              "  if (eocdOffset < 0) return files;",
              "",
              "  const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);",
              "  const totalEntries = dv.getUint16(eocdOffset + 10, true);",
              "  const cdOffset = dv.getUint32(eocdOffset + 16, true);",
              "",
              "  let p = cdOffset;",
              "  for (let i = 0; i < totalEntries && p + 46 <= buf.length; i++) {",
              "    if (!(buf[p] === 0x50 && buf[p + 1] === 0x4B && buf[p + 2] === 0x01 && buf[p + 3] === 0x02)) break;",
              "",
              "    const compressedSize = dv.getUint32(p + 20, true);",
              "    const uncompressedSize = dv.getUint32(p + 24, true);",
              "    const fileNameLen = dv.getUint16(p + 28, true);",
              "    const extraLen = dv.getUint16(p + 30, true);",
              "    const commentLen = dv.getUint16(p + 32, true);",
              "",
              "    const nameBytes = buf.slice(p + 46, p + 46 + fileNameLen);",
              "    const name = new TextDecoder('utf-8').decode(nameBytes);",
              "",
              "    files.push({",
              "      idx: i + 1,",
              "      name: name,",
              "      compressedFmt: formatSize(compressedSize),",
              "      uncompressedFmt: formatSize(uncompressedSize)",
              "    });",
              "",
              "    p += 46 + fileNameLen + extraLen + commentLen;",
              "  }",
              "",
              "  return files;",
              "}",
              "",
              "const files = isZip ? parseZipFileList(bytes) : [];",
              "const contentType = pm.response.headers.get('Content-Type') || 'n/a';",
              "const contentDisposition = pm.response.headers.get('Content-Disposition') || 'n/a';",
              "",
              "pm.test('Status code is 200', () => pm.response.to.have.status(200));",
              "pm.test('Response is a valid ZIP file (PK header)', () => {",
              "  pm.expect(isZip).to.be.true;",
              "});",
              "pm.test('ZIP contains at least one file', () => {",
              "  pm.expect(files.length).to.be.above(0);",
              "});",
              "",
              "const template = `",
              "<style>",
              "  body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; padding: 20px; color: #222; background: #fafbfc; }",
              "  .card { border: 1px solid #e1e4e8; border-radius: 8px; padding: 20px; margin-bottom: 16px; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }",
              "  .header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }",
              "  .badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-weight: 600; font-size: 12px; letter-spacing: 0.3px; }",
              "  .badge-ok { background: #d4f4dd; color: #0a7a2f; }",
              "  .badge-fail { background: #fde2e2; color: #b42318; }",
              "  h2 { margin: 0; font-size: 18px; }",
              "  .check { color: #0a7a2f; font-weight: 700; margin-right: 6px; }",
              "  .fail { color: #b42318; font-weight: 700; margin-right: 6px; }",
              "  table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 13px; }",
              "  th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #eef0f2; }",
              "  th { background: #f6f8fa; font-weight: 600; }",
              "  .meta { display: grid; grid-template-columns: 220px 1fr; gap: 8px 16px; font-size: 13px; }",
              "  .meta div:nth-child(odd) { color: #666; font-weight: 600; }",
              "  .mono { font-family: ui-monospace, Menlo, Consolas, monospace; }",
              "</style>",
              "",
              "<div class=\"card\">",
              "  <div class=\"header\">",
              "    <h2>ZIP Download Verification</h2>",
              "    {{#if isZip}}<span class=\"badge badge-ok\">VALID ZIP</span>{{else}}<span class=\"badge badge-fail\">INVALID</span>{{/if}}",
              "  </div>",
              "",
              "  <div class=\"meta\">",
              "    <div>ZIP Signature</div>",
              "    <div>",
              "      {{#if isZip}}<span class=\"check\">&#10003;</span>{{else}}<span class=\"fail\">&#10007;</span>{{/if}}",
              "      <span class=\"mono\">{{magicHex}}</span> <span style=\"color:#888\">(expected 50 4B 03 04)</span>",
              "    </div>",
              "    <div>Total Size</div>",
              "    <div>{{sizeFmt}} <span class=\"mono\" style=\"color:#888\">({{totalSize}} bytes)</span></div>",
              "    <div>Content-Type</div>",
              "    <div class=\"mono\">{{contentType}}</div>",
              "    <div>Content-Disposition</div>",
              "    <div class=\"mono\">{{contentDisposition}}</div>",
              "    <div>Files in Archive</div>",
              "    <div><strong>{{fileCount}}</strong></div>",
              "  </div>",
              "</div>",
              "",
              "{{#if fileCount}}",
              "<div class=\"card\">",
              "  <h2 style=\"margin-top:0\">Archive Contents</h2>",
              "  <table>",
              "    <thead>",
              "      <tr>",
              "        <th style=\"width:40px\">#</th>",
              "        <th>File Name</th>",
              "        <th style=\"width:120px\">Compressed</th>",
              "        <th style=\"width:120px\">Uncompressed</th>",
              "      </tr>",
              "    </thead>",
              "    <tbody>",
              "      {{#each files}}",
              "      <tr>",
              "        <td>{{idx}}</td>",
              "        <td class=\"mono\">{{name}}</td>",
              "        <td>{{compressedFmt}}</td>",
              "        <td>{{uncompressedFmt}}</td>",
              "      </tr>",
              "      {{/each}}",
              "    </tbody>",
              "  </table>",
              "</div>",
              "{{/if}}",
              "`;",
              "",
              "const visualizerData = {",
              "  isZip: isZip,",
              "  magicHex: magicHex,",
              "  totalSize: totalSize,",
              "  sizeFmt: formatSize(totalSize),",
              "  contentType: contentType,",
              "  contentDisposition: contentDisposition,",
              "  fileCount: files.length,",
              "  files: files",
              "};",
              "",
              "if (pm.visualizer && typeof pm.visualizer.set === 'function') {",
              "  pm.visualizer.set(template, visualizerData);",
              "}"
            ]
          }
        }
      ],
      "request": {
        "method": "GET",
        "url": "{{DOQLO_BASE_URL}}/v1/bulkfill/export-jobs/{{DOQLO_JOB_ID}}/download",
        "description": {
          "content": "Download the completed ZIP result.\n\n- Run this request after the job status is `completed`.\n- The response is an `application/zip` download.\n- In Postman, use `Send and Download` to save the ZIP file.\n- The ZIP contains generated row PDFs and `manifest.json`.",
          "type": "text/markdown"
        }
      },
      "response": [
        {
          "name": "200 ZIP download",
          "originalRequest": {
            "method": "GET",
            "header": [],
            "url": "{{DOQLO_BASE_URL}}/v1/bulkfill/export-jobs/{{DOQLO_JOB_ID}}/download"
          },
          "status": "OK",
          "code": 200,
          "_postman_previewlanguage": "text",
          "header": [
            {
              "key": "Content-Type",
              "value": "application/zip"
            },
            {
              "key": "Content-Disposition",
              "value": "attachment; filename=\"bulk_export_example.zip\""
            }
          ],
          "cookie": [],
          "body": "Binary ZIP response. Use Send and Download in Postman. The ZIP contains generated row PDFs and manifest.json."
        }
      ]
    }
  ]
}
