{
  "info": {
    "name": "KRA Agent SBX API v1",
    "_postman_id": "8f3c1d20-7b41-4f6e-9d2a-3c5e7a91b204",
    "description": "# KRA Agent API\n\nThe KRA Agent API by [Symatech Labs Ltd](https://symatechlabs.com/) makes it easy to submit sales invoices to the Kenya Revenue Authority (KRA). It connects directly to your point-of-sale (POS) systems, formats invoices automatically, and manages submissions with automatic retries during KRA downtime.\n\n## Base URL\n\n```\nhttps://kra-agent-sbx.symatechlabs.com/api\n```\n\nAll endpoints in this collection use the `{{base_url}}` variable, set by default to `https://kra-agent-sbx.symatechlabs.com/api`.\n\n## Key Features\n\n1. **Flexible Payload Mapping**: Different POS systems structure their data differently. Custom mappings translate your invoice and tax codes into KRA-compliant format without any code changes.\n\n2. **Isolated POS Integrations**: Manage and authenticate each till independently under your account to keep credentials secure.\n\n3. **Reliable Queueing & Duplicate Protection**: Invoices are safely queued and retried if network or server issues occur. Built-in duplicate detection prevents double submissions.\n\n## Authentication\n\n- **User Authentication**: Log in with your email and password to get a bearer access token (`Authorization: Bearer <token>`). Use this token to manage accounts, tills, catalog items, and mappings.\n- **POS Till Ingestion**: POS devices send sales invoices directly using a till API key (`Authorization: Bearer <key_id>.<secret>`) or a body-authenticated SenderId signature.\n\n## Response Envelope\n\nEndpoints return a standard JSON response:\n\n```json\n{ \"success\": true, \"message\": \"Description of the result\", \"data\": { } }\n```\n\n## Getting Started\n\n1. Use **Login** with your email and password to get your access token.\n2. Create or find your POS integration to get your `integration_id`.\n3. Generate an API credential (`key_id` and `secret`) for your till.\n4. Set up tax and item mappings for your POS format.\n5. Register your catalog items and start submitting sales invoices.\n\nFor documentation and support, visit [Symatech Labs Ltd](https://symatechlabs.com/).",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "item": [
    {
      "name": "1. Authentication",
      "description": "User sign-in and session management. Log in with your email and password to receive a bearer access token for managing accounts, configurations, and reports.",
      "item": [
        {
          "name": "Login",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/auth/login",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "auth",
                "login"
              ]
            },
            "description": "Sign in with your email and password to receive an access token.\n\n**Notes**\n- Rate limited to 5 attempts per minute per IP.\n- For security, invalid emails and incorrect passwords return the same generic message.\n- Deactivated users receive a 403 Forbidden response.\n- Both successful and failed sign-in attempts are recorded in the audit trail.\n\nThe response includes your user profile and permissions list.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"{your registered email}\",\n  \"password\": \"{your password}\",\n  \"device_name\": \"ops-laptop\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Signed in.\",\n  \"data\": {\n    \"token\": \"3|hR9xkP2wQ7...\",\n    \"user\": {\n      \"id\": 1,\n      \"name\": \"Platform Admin\",\n      \"email\": \"{your registered email}\",\n      \"account_id\": null,\n      \"account\": null,\n      \"is_platform_user\": true,\n      \"roles\": [\n        \"platform-admin\"\n      ],\n      \"permissions\": [\n        \"accounts.view\",\n        \"documents.view\",\n        \"billing.payments.record\"\n      ]\n    }\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Bad credentials",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Validation failed.\",\n  \"errors\": {\n    \"email\": [\n      \"These credentials do not match our records.\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "403 Deactivated",
              "status": "Forbidden",
              "code": 403,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"This account has been deactivated. Contact your administrator.\"\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "429 Rate limited",
              "status": "Too Many Requests",
              "code": 429,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Too many requests. Retry after the interval in the Retry-After header.\"\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('login succeeds', () => pm.response.to.have.status(200));",
                  "const b = pm.response.json();",
                  "if (b.success) { pm.collectionVariables.set('token', b.data.token); }",
                  "pm.test('returns a token', () => pm.expect(b.data.token).to.be.a('string'));"
                ]
              }
            }
          ]
        },
        {
          "name": "Forgot password",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/auth/forgot-password",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "auth",
                "forgot-password"
              ]
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"jane@merchant.co.ke\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "description": "**Public, unauthenticated.** Starts a password reset: if the email belongs to an **active** account, a single-use token is generated and a `ResetPasswordMail` is **queued** (delivery is async via the mail queue).\n\n**Anti-enumeration:** the response is identical whether or not the address exists - so it can't be used to discover which emails are registered. Throttled to 6/min.\n\nThe emailed link points at the operator UI (`FRONTEND_URL + reset-password.html`) carrying `?token=…&email=…`, which the UI replays against **Reset password** below. The token expires in 60 minutes."
          },
          "response": [
            {
              "name": "200 OK",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"If that email belongs to an account, a password reset link is on its way.\",\n  \"data\": null\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": ""
                }
              }
            }
          ]
        },
        {
          "name": "Reset password",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/auth/reset-password",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "auth",
                "reset-password"
              ]
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"jane@merchant.co.ke\",\n  \"token\": \"<token-from-the-emailed-link>\",\n  \"password\": \"BrandNewP@ss123\",\n  \"password_confirmation\": \"BrandNewP@ss123\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "description": "**Public, unauthenticated.** Completes a reset. The `token` (from the emailed link) is validated and **consumed** - it cannot be replayed, and it is rejected once expired (60 min) or if it was minted for a different email.\n\n`password` must be **min 12 chars with letters, numbers and symbols** and match `password_confirmation`.\n\nOn success **every one of the user's API tokens is revoked** (a reset invalidates old sessions), and the event is audited. An invalid/expired/mismatched token returns a single generic `422` on `token` - never confirming which of the three it was. Throttled to 6/min."
          },
          "response": [
            {
              "name": "200 Reset",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Your password has been reset. Sign in with your new password.\",\n  \"data\": null\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Invalid or expired token",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"token\": [\n      \"This password reset link is invalid or has expired. Request a new one.\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": ""
                }
              }
            }
          ]
        },
        {
          "name": "Sandbox self-signup",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/auth/sandbox-signup",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "auth",
                "sandbox-signup"
              ]
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Jane Founder\",\n  \"email\": \"jane@newco.co.ke\",\n  \"password\": \"Sandb0x!Pass99\",\n  \"password_confirmation\": \"Sandb0x!Pass99\",\n  \"company_name\": \"NewCo Ltd\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "description": "**Public, unauthenticated. Sandbox tier only** - on production this route 404s as if it does not exist (`config('kraagent.environment')`, derived from `APP_ENV`).\n\nOne request atomically provisions, in a single transaction:\n1. a merchant **Account** (`status: pending_verification`, a generated sandbox PIN),\n2. an **account-admin User**,\n3. a default **PosIntegration** (till) on the `sandbox_free` plan with long complimentary coverage (so filing is permitted through the normal billing gate - no bypass),\n4. an initial **Bearer API key** (`key_id.secret`) - returned **once**,\n5. a **starter recipe** - an editable payload mapping matching the documented nested sample sale, plus standard tax codes (`A`→16% VAT, `E`→exempt) - so the tenant can file the sample immediately and then adapt it.\n\nA **queued** verification email is sent. The account can **ingest and store** sales immediately, but **will not file** them until the email is verified (see *Verify email*). Throttled to **3 signups/IP/hour**."
          },
          "response": [
            {
              "name": "201 Created",
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Sandbox account created. Copy your API secret now \\u2014 it will not be shown again.\",\n  \"data\": {\n    \"environment\": \"sandbox\",\n    \"account\": {\n      \"id\": 42,\n      \"name\": \"NewCo Ltd\"\n    },\n    \"user\": {\n      \"id\": 88,\n      \"email\": \"jane@newco.co.ke\"\n    },\n    \"pos_integration\": {\n      \"id\": 57,\n      \"code\": \"sbx-a1b2c3\"\n    },\n    \"api_key\": {\n      \"key_id\": \"kra_9x8y7z6w5v4u\",\n      \"secret\": \"sk_Ab12Cd34Ef56Gh78Ij90Kl\",\n      \"authorization_header\": \"Bearer kra_9x8y7z6w5v4u.sk_Ab12Cd34Ef56Gh78Ij90Kl\"\n    },\n    \"verification\": \"A verification link has been emailed to jane@newco.co.ke. The sandbox will accept and store sales immediately, but will not file them until the email is verified.\",\n    \"starter_mappings\": true\n  }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "404 On production",
              "status": "Not Found",
              "code": 404,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Not found.\"\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": ""
                }
              }
            }
          ]
        },
        {
          "name": "Verify email",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/auth/verify-email",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "auth",
                "verify-email"
              ]
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"jane@newco.co.ke\",\n  \"token\": \"<token-from-the-emailed-link>\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "description": "**Public, unauthenticated.** Same pattern as password reset: the verification email links to the frontend page (`FRONTEND_URL` + `verify-email.html`) carrying `?token=…&email=…`, and that page replays `{email, token}` here.\n\nThe token is **single-use** and **expires in 60 minutes**; an invalid/expired/replayed token returns a generic `422` on `token` (never confirming which). On success the user's email is marked verified and the owning **account flips `pending_verification` → `active`**, unblocking filing. A second call for an already-verified email returns *already verified*."
          },
          "response": [
            {
              "name": "200 Verified",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Email verified. Your sandbox account is now active \\u2014 you can file sales.\",\n  \"data\": {\n    \"verified\": true\n  }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Invalid/expired token",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"The given data was invalid.\",\n  \"errors\": {\n    \"token\": [\n      \"This verification link is invalid or has expired. Request a new one.\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": ""
                }
              }
            }
          ]
        },
        {
          "name": "Resend verification email",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/auth/email/resend",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "auth",
                "email",
                "resend"
              ]
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"jane@newco.co.ke\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "description": "Re-queues the verification email if the address exists and is still unverified. **Anti-enumeration:** the response is identical whether or not the address exists or is already verified. Throttled to 6/min."
          },
          "response": [
            {
              "name": "200 OK",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"If that email needs verifying, a new link is on its way.\",\n  \"data\": null\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": ""
                }
              }
            }
          ]
        },
        {
          "name": "Who am I",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/auth/me",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "auth",
                "me"
              ]
            },
            "description": "Returns the acting user with their roles and effective permissions."
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Success\",\n  \"data\": {\n    \"id\": 1,\n    \"name\": \"Platform Admin\",\n    \"email\": \"{your registered email}\",\n    \"account_id\": null,\n    \"is_platform_user\": true,\n    \"roles\": [\n      \"platform-admin\"\n    ],\n    \"permissions\": [\n      \"accounts.view\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Logout",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/auth/logout",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "auth",
                "logout"
              ]
            },
            "description": "Deletes the token used for this request. Other tokens for the same user are unaffected, so revoking one device does not sign out the rest."
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Signed out.\",\n  \"data\": null\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "2. POS Ingestion",
      "description": "Endpoints for submitting sales and receipts directly from your POS tills, authenticated with a till API key:\n\n```\nAuthorization: Bearer <key_id>.<secret>\n```\nor `X-Api-Key` + `X-Api-Secret`.\n\n**Native POS payloads:** Send your POS system's native JSON format directly. The integration's mapping rules automatically translate it to KRA requirements.\n\n**Duplicate prevention:** Each receipt is tracked by its reference. If you resend an already submitted invoice, the existing record is returned without double-filing.\n\n**Reliable queuing:** Sales data is safely received and queued, ensuring nothing is lost even during maintenance or account renewals.",
      "item": [
        {
          "name": "Submit a sale (SenderId body auth - no header)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/pos/ingest",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "pos",
                "ingest"
              ]
            },
            "description": "For a POS that **cannot send an auth header**. There is **no `Authorization`** here - the sale is authenticated by a provider-generated secret carried in the body at `Invoice.SenderId` (a random per-sender token). We resolve it by an indexed hash to exactly one till.\n\n**Requires:** the till's `auth_scheme` is `sender_id` (set on the integration), and the `SenderId` has been registered (see *Register a SenderId*). Otherwise every call returns a generic `401`.\n\nEverything else is identical to the header-authenticated path - same mapping, same `202`/duplicate idempotency (on `TraderSystemInvoiceNumber`), same billing hold. Sales only. The `SenderId` is stored in the payload as received.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"Invoice\": {\n    \"SenderId\": \"{{pos_sender_id}}\",\n    \"InvoiceTimestamp\": \"2026-07-21T10:04:00\",\n    \"InvoiceCategory\": \"Tax invoice\",\n    \"TraderSystemInvoiceNumber\": \"REC-57\",\n    \"PINOfBuyer\": \"\",\n    \"InvoiceType\": \"Original\",\n    \"TotalInvoiceAmount\": 442.38,\n    \"TotalTaxableAmount\": 381.36,\n    \"TotalTaxAmount\": 61.02,\n    \"ItemDetails\": [\n      { \"HSDesc\": \"Strawberry Milkshake\", \"HSCode\": \"MILK-09\", \"TaxRate\": 16, \"UnitPrice\": 381.36, \"Quantity\": 1, \"ItemAmount\": 381.36, \"TaxAmount\": 61.02 }\n    ]\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "202 Accepted",
              "status": "Accepted",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Document accepted for submission.\",\n  \"data\": { \"document_id\": 90002, \"reference\": \"REC-57\", \"status\": \"queued\" }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "401 Invalid sender credential",
              "status": "Unauthorized",
              "code": 401,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Invalid sender credential.\"\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ]
        },
        {
          "name": "Check a sale status (SenderId body auth)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/pos/ingest/REC-57",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "pos",
                "ingest",
                "REC-57"
              ]
            },
            "description": "The status poll for a **sender_id** till. It cannot authenticate the bearer `GET /v1/pos/documents/{ref}` (no header, and a GET has no body to carry the secret), so it polls here: the **reference is in the path**, and the **SenderId secret rides in the body** (`Invoice.SenderId`) - never in the URL or query, so it stays out of access logs.\n\nReturns the same document status as the bearer status endpoint (`queued` / `accepted` with the KRA control code / `retry_scheduled` / `rejected`). It only ever returns **this till's own** documents - another till's reference is a `404`.\n\n**Prefer `webhook` or `sync` delivery** for outcomes; use this poll as the fallback when a webhook is missed.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"Invoice\": {\n    \"SenderId\": \"{{pos_sender_id}}\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Status",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Success\",\n  \"data\": { \"document_id\": 90002, \"reference\": \"REC-57\", \"status\": \"accepted\", \"kra_control_code\": \"KRACU0300003914\", \"kra_invoice_number\": \"25\" }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "404 Not this till's document",
              "status": "Not Found",
              "code": 404,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"No document with reference [REC-57] has been received from this POS integration.\"\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ]
        },
        {
          "name": "Submit a sales invoice (nested format)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{pos_key_id}}.{{pos_secret}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "X-Client-Request-Id",
                "value": "{{$guid}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/pos/documents",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "pos",
                "documents"
              ]
            },
            "description": "Files one document. The default type is `sales_invoice`; override with the\n`X-Document-Type` header or the typed URL.\n\n**Responses by delivery mode**\n| Mode | Response |\n|---|---|\n| `async` | 202 immediately; poll the status endpoint |\n| `webhook` | 202 immediately; we POST the signed result to your URL |\n| `sync` | 201 with the control code, or 202 if it could not complete in time |\n\n**Optional headers**\n- `X-Client-Request-Id` - echoed onto the document for your own reconciliation.\n- `X-Document-Type` - one of `sales_invoice`, `credit_note`, `debit_note`,\n  `stock_movement`, `item_registration`, `customer_registration`.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"receipt\": {\n    \"no\": \"RST-8891\",\n    \"issued\": \"2026-07-21T10:04:00+03:00\"\n  },\n  \"customer\": {\n    \"name\": \"Walk-in\",\n    \"pin\": null\n  },\n  \"items\": [\n    {\n      \"sku\": \"BF-01\",\n      \"desc\": \"Beef Stew\",\n      \"qty\": 2,\n      \"price\": 650.0,\n      \"vat\": \"A\"\n    },\n    {\n      \"sku\": \"CH-02\",\n      \"desc\": \"Chapati\",\n      \"qty\": 3,\n      \"price\": 50.0,\n      \"vat\": \"A\"\n    }\n  ],\n  \"totals\": {\n    \"gross\": 1450.0\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "202 Accepted (async)",
              "status": "Accepted",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Document accepted for submission.\",\n  \"data\": {\n    \"document_id\": 90001,\n    \"reference\": \"RST-8891\",\n    \"document_type\": \"sales_invoice\",\n    \"status\": \"queued\",\n    \"total_incl_tax\": 1450.0,\n    \"total_tax\": 200.0,\n    \"currency\": \"KES\",\n    \"received_at\": \"2026-07-21T10:04:02+00:00\"\n  }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"receipt\": {\n    \"no\": \"RST-8891\",\n    \"issued\": \"2026-07-21T10:04:00+03:00\"\n  },\n  \"customer\": {\n    \"name\": \"Walk-in\",\n    \"pin\": null\n  },\n  \"items\": [\n    {\n      \"sku\": \"BF-01\",\n      \"desc\": \"Beef Stew\",\n      \"qty\": 2,\n      \"price\": 650.0,\n      \"vat\": \"A\"\n    },\n    {\n      \"sku\": \"CH-02\",\n      \"desc\": \"Chapati\",\n      \"qty\": 3,\n      \"price\": 50.0,\n      \"vat\": \"A\"\n    }\n  ],\n  \"totals\": {\n    \"gross\": 1450.0\n  }\n}"
                },
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "201 Created (sync mode)",
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Document accepted by the revenue authority.\",\n  \"data\": {\n    \"document_id\": 90001,\n    \"reference\": \"RST-8891\",\n    \"document_type\": \"sales_invoice\",\n    \"status\": \"accepted\",\n    \"kra_control_code\": \"KRACU00918273645\",\n    \"kra_invoice_number\": \"INV0000090001\",\n    \"verification_url\": \"https://etims.kra.go.ke/verify/KRACU00918273645\",\n    \"provider_reference\": \"AGX-77120\",\n    \"total_incl_tax\": 1450.0,\n    \"total_tax\": 200.0,\n    \"currency\": \"KES\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "200 Duplicate replay",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"This document reference has already been received; returning the existing record.\",\n  \"data\": {\n    \"document_id\": 90001,\n    \"reference\": \"RST-8891\",\n    \"status\": \"accepted\",\n    \"duplicate\": true,\n    \"kra_control_code\": \"KRACU00918273645\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "202 Held (subscription unpaid)",
              "status": "Accepted",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Document accepted and stored. Submission is on hold: Subscription is not paid up. It will be submitted automatically once this is resolved.\",\n  \"data\": {\n    \"document_id\": 90042,\n    \"reference\": \"RST-8891\",\n    \"status\": \"on_hold\",\n    \"hold_reason\": \"billing_suspended\",\n    \"total_incl_tax\": 1450.0\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Unmapped tax code",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Tax code [UNKNOWN] has no mapping for this POS integration. Add a code mapping of type [tax] so the correct rate is applied \\u2014 guessing a tax rate would file the wrong amount.\",\n  \"errors\": {\n    \"error_code\": \"UNMAPPED_TAX_CODE\",\n    \"field\": \"tax_code\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Invalid buyer PIN",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Buyer PIN [P05XXXX] is not a valid KRA PIN. It must be a letter, nine digits and a letter (for example P051234567X). Leave it empty for a walk-in customer \\u2014 a wrong PIN would file the sale against the wrong taxpayer.\",\n  \"errors\": {\n    \"error_code\": \"INVALID_CUSTOMER_PIN\",\n    \"field\": \"customer_pin\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Totals disagree",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"The declared total of 1500.00 does not agree with the sum of the lines, 1450.00 (difference 50.00, tolerance 0.01). Filing a total that disagrees with its lines would be rejected downstream, so it is refused here where it can still be corrected.\",\n  \"errors\": {\n    \"error_code\": \"TOTALS_DISAGREE\",\n    \"field\": \"total_incl_tax\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "401 Bad credentials",
              "status": "Unauthorized",
              "code": 401,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Invalid API credentials.\"\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "403 Integration disabled",
              "status": "Forbidden",
              "code": 403,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"This POS integration has been disabled. Contact support.\"\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "413 Payload too large",
              "status": "Content Too Large",
              "code": 413,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Payload is 2097152 bytes; the maximum accepted is 1048576 bytes.\"\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "503 Planned maintenance",
              "status": "Service Unavailable",
              "code": 503,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                },
                {
                  "key": "Retry-After",
                  "value": "600"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"The service is temporarily unavailable for planned maintenance. Nothing you have already sent is affected. Hold this document and send it again after 600 seconds \\u2014 it will be accepted normally.\",\n  \"errors\": {\n    \"retry_after_seconds\": 600,\n    \"reason\": \"Database migration\",\n    \"since\": \"2026-07-21T02:00:00+00:00\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('accepted', () => pm.expect([200,201,202]).to.include(pm.response.code));",
                  "const b = pm.response.json();",
                  "if (b.data && b.data.document_id) { pm.collectionVariables.set('document_id', b.data.document_id); }",
                  "pm.test('reference echoed back', () => pm.expect(b.data.reference).to.be.a('string'));"
                ]
              }
            }
          ]
        },
        {
          "name": "Submit a sales invoice (flat format)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{pos_key_id}}.{{pos_secret}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/pos/documents",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "pos",
                "documents"
              ]
            },
            "description": "The same endpoint, a completely different JSON dialect. Nothing changes server-side except which mapping row is active for the integration - this is the point of holding mappings as data.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"invoice_number\": \"RT-4410\",\n  \"date\": \"21/07/2026\",\n  \"customer_name\": \"Walk-in\",\n  \"lines\": [\n    {\n      \"code\": \"BF-01\",\n      \"name\": \"Beef Stew\",\n      \"quantity\": 2,\n      \"unit_price\": 560.34,\n      \"tax_code\": \"VAT16\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "202 Accepted",
              "status": "Accepted",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Document accepted for submission.\",\n  \"data\": {\n    \"document_id\": 90002,\n    \"reference\": \"RT-4410\",\n    \"status\": \"queued\",\n    \"total_incl_tax\": 1300.0,\n    \"currency\": \"KES\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Submit a credit note",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{pos_key_id}}.{{pos_secret}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/pos/documents/type/credit_note",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "pos",
                "documents",
                "type",
                "credit_note"
              ]
            },
            "description": "A credit note **must** carry two things, both refused if absent:\n\n- a reference to the document it amends (mapped to `original_document_ref`) - without it the note cannot be reconciled against the sale it reverses;\n- a KRA **reason code** (mapped to `reason_code`) - eTIMS rejects a credit note that does not say WHY it reverses the sale. Valid codes: `01` Missing quantity, `02` Missing item, `03` Damaged, `04` Wasted, `05` Raw material shortage, `06` Refund, `07` Wrong quantity, `08` Wrong item, `09` Wrong price, `10` Cancelled sale, `11` Other.\n\n**Ordering is safe.** A note references its original by the sale's own invoice number, which exists only once the original has been filed. If you send the note before its original finishes filing, the note is **not** rejected - it goes to `retry_scheduled` with `error_code: PARENT_NOT_FILED` and drains automatically once the original is filed. Poll the note's reference for the outcome.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"receipt\": {\n    \"no\": \"CN-0007\",\n    \"original\": \"RST-8891\",\n    \"reason\": \"06\",\n    \"issued\": \"2026-07-22T09:00:00+03:00\"\n  },\n  \"items\": [\n    {\n      \"sku\": \"BF-01\",\n      \"desc\": \"Beef Stew (returned)\",\n      \"qty\": 1,\n      \"price\": 650.0,\n      \"vat\": \"A\"\n    }\n  ],\n  \"totals\": {\n    \"gross\": 650.0\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "202 Accepted",
              "status": "Accepted",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Document accepted for submission.\",\n  \"data\": {\n    \"document_id\": 90003,\n    \"reference\": \"CN-0007\",\n    \"document_type\": \"credit_note\",\n    \"status\": \"queued\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Missing original reference",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"A credit_note must reference the document it amends, but no value was mapped to [original_document_ref].\",\n  \"errors\": {\n    \"error_code\": \"MISSING_ORIGINAL_REFERENCE\",\n    \"field\": \"original_document_ref\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Missing / invalid reason code",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Reason code [99] is not a KRA credit-note reason. Valid codes are: 01, 02, 03, 04, 05, 06, 07, 08, 09, 10, 11.\",\n  \"errors\": {\n    \"error_code\": \"INVALID_REASON_CODE\",\n    \"field\": \"reason_code\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Legacy single-endpoint ingest (routing_mode: custom)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{pos_key_id}}.{{pos_secret}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "X-Client-Request-Id",
                "value": "{{$guid}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/pos/documents",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "pos",
                "documents"
              ]
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"receipt\": {\n    \"no\": \"CN-1007\",\n    \"issued\": \"2026-07-21T10:04:00+03:00\",\n    \"original\": \"RST-8891\",\n    \"reason\": \"06\"\n  },\n  \"customer\": {\n    \"name\": \"Walk-in\"\n  },\n  \"items\": [\n    {\n      \"sku\": \"BF-01\",\n      \"desc\": \"Beef Stew\",\n      \"qty\": 1,\n      \"price\": 650.0,\n      \"vat\": \"A\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "description": "**For a till whose `routing_mode` is `custom`.** The payload carries **no** type hint (no `documents/type/...`, no `X-Document-Type`, no body `document_type`). Because it also carries an original reference and a reason code, it fits the integration's active **credit_note** mapping, so the engine resolves it to `credit_note` (a plain sale with neither field would default to `sales_invoice`). Subpaths are scoped under `/documents` and `/ingest` only - there is no bare `/pos/{subpath}` route. Duplicate references replay idempotently regardless of routing mode."
          },
          "response": [
            {
              "name": "202 Accepted (inferred credit_note)",
              "status": "Accepted",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Document received and queued for filing.\",\n  \"data\": {\n    \"document_id\": 7731,\n    \"reference\": \"CN-1007\",\n    \"document_type\": \"credit_note\",\n    \"original_document_ref\": \"RST-8891\",\n    \"status\": \"queued\"\n  }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": ""
                }
              }
            }
          ]
        },
        {
          "name": "Ingest Document (Custom POS Subpath)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{pos_key_id}}.{{pos_secret}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "X-Client-Request-Id",
                "value": "{{$guid}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/pos/documents/api/invoice?store_code=ST101&pos_ver=2.4",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "pos",
                "documents",
                "api",
                "invoice"
              ],
              "query": [
                {
                  "key": "store_code",
                  "value": "ST101"
                },
                {
                  "key": "pos_ver",
                  "value": "2.4"
                }
              ]
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"receipt\": {\n    \"no\": \"RST-8891\",\n    \"issued\": \"2026-07-21T10:04:00+03:00\"\n  },\n  \"customer\": {\n    \"name\": \"Walk-in\",\n    \"pin\": null\n  },\n  \"items\": [\n    {\n      \"sku\": \"BF-01\",\n      \"desc\": \"Beef Stew\",\n      \"qty\": 2,\n      \"price\": 650.0,\n      \"vat\": \"A\"\n    },\n    {\n      \"sku\": \"CH-02\",\n      \"desc\": \"Chapati\",\n      \"qty\": 3,\n      \"price\": 50.0,\n      \"vat\": \"A\"\n    }\n  ],\n  \"totals\": {\n    \"gross\": 1450.0\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "description": "**Demonstrates support for legacy/appliance POS systems that append their own subpath** after the ingestion endpoint (e.g. `/pos/documents/api/invoice`, or arbitrary depth like `/pos/documents/newapp/api/invoice/send`). The appended tail does not change auth or payload handling - it is only inspected for a document-type keyword (`invoice`, `credit_note`, `debit_note`) as a last-resort hint after `X-Document-Type` and the body's `document_type`.\n\n**Query parameters are ignored** for payload purposes: `?store_code=…&pos_ver=…` never contaminate the JSON body - only `Content-Type: application/json` request bodies are read as the sale.\n\n**Precedence is preserved:** a SINGLE trailing segment is still a status-poll reference (`GET /pos/documents/{reference}`, or `POST /pos/ingest/{reference}` for body-secret tills), never an ingestion. The standard `/pos/documents` and `/pos/ingest` endpoints are unchanged.\n\nThe same subpath tolerance applies to the body-secret (`sender_id`) endpoint: `POST /pos/ingest/api/invoice`."
          },
          "response": [
            {
              "name": "202 Accepted",
              "status": "Accepted",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Document received and queued for filing.\",\n  \"data\": {\n    \"document_id\": 9911,\n    \"reference\": \"RST-8891\",\n    \"document_type\": \"sales_invoice\",\n    \"status\": \"queued\"\n  }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": ""
                }
              }
            }
          ]
        },
        {
          "name": "Submit end-of-day totals (Z-report)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{pos_key_id}}.{{pos_secret}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/pos/daily-summary",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "pos",
                "daily-summary"
              ]
            },
            "description": "Your till's own figures for the day. **This is the only check that can reveal a\nsale that never reached us at all** - every other report reconciles what we\nreceived, and a document that never arrived leaves no trace.\n\nSend it once at close of business. Re-sending for the same date corrects the\nearlier figures rather than creating a second, contradictory record.\n\n**Any gap is sorted into buckets, and only ONE is yours to fix:**\n\n| Bucket | Meaning | Your action |\n|---|---|---|\n| `never_received` | It is on your till and nowhere else | **Re-send it** |\n| `in_progress` | We hold it and are still filing it | Nothing |\n| `held` | We hold it; the subscription is unpaid | Settle the account |\n| `failed` | We hold it; it was refused | We have contacted you |\n\n`200` = the figures agree. `202` = they do not; read `breakdown` and\n`resend_required`. We will never ask you to re-send a document we already hold.\n\n**The business date is yours, not ours.** Days are bounded by the integration's\nown `timezone` (default `Africa/Nairobi`), so a receipt issued at 01:30 EAT\nbelongs to that day even though the server clock still reads the previous date\nin UTC. \"Not in the future\" is judged the same way - a till closing at 23:30 EAT\ncan report a date the UTC server thinks is tomorrow.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"business_date\": \"2026-07-21\",\n  \"document_count\": 412,\n  \"gross_total\": 604210.0,\n  \"tax_total\": 83340.0,\n  \"first_reference\": \"RST-8891\",\n  \"last_reference\": \"RST-9302\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Everything reached us",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Totals match. Every receipt your till recorded has reached us.\",\n  \"data\": {\n    \"id\": 51,\n    \"pos_integration_id\": 10,\n    \"business_date\": \"2026-07-21\",\n    \"status\": \"matched\",\n    \"revision\": 1,\n    \"declared\": {\n      \"count\": 412,\n      \"gross\": 604210.0,\n      \"tax\": 83340.0,\n      \"first_reference\": \"RST-8891\",\n      \"last_reference\": \"RST-9302\"\n    },\n    \"received\": {\n      \"count\": 412,\n      \"gross\": 604210.0,\n      \"tax\": 83340.0\n    },\n    \"breakdown\": {\n      \"accepted\": 410,\n      \"in_progress\": 2,\n      \"held\": 0,\n      \"failed\": 0,\n      \"never_received\": 0\n    },\n    \"variance\": {\n      \"count\": 0,\n      \"gross\": 0.0,\n      \"tax\": 0.0\n    },\n    \"missing_references\": [],\n    \"resend_required\": false\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "202 Four receipts never arrived",
              "status": "Accepted",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Your till recorded 412 receipts but we only ever received 408. 4 never reached us and must be re-sent.\",\n  \"data\": {\n    \"id\": 51,\n    \"business_date\": \"2026-07-21\",\n    \"status\": \"variance\",\n    \"declared\": {\n      \"count\": 412,\n      \"gross\": 604210.0,\n      \"tax\": 83340.0\n    },\n    \"received\": {\n      \"count\": 408,\n      \"gross\": 598890.0,\n      \"tax\": 82606.0\n    },\n    \"breakdown\": {\n      \"accepted\": 408,\n      \"in_progress\": 0,\n      \"held\": 0,\n      \"failed\": 0,\n      \"never_received\": 4\n    },\n    \"variance\": {\n      \"count\": 4,\n      \"gross\": 5320.0,\n      \"tax\": 734.0\n    },\n    \"missing_references\": [\n      \"RST-8903\",\n      \"RST-8904\",\n      \"RST-8917\",\n      \"RST-8952\"\n    ],\n    \"resend_required\": true\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "202 We hold them all - nothing to re-send",
              "status": "Accepted",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"We hold all 412 receipts. Some are still being processed or held \\u2014 nothing needs re-sending.\",\n  \"data\": {\n    \"business_date\": \"2026-07-21\",\n    \"status\": \"variance\",\n    \"breakdown\": {\n      \"accepted\": 0,\n      \"in_progress\": 0,\n      \"held\": 412,\n      \"failed\": 0,\n      \"never_received\": 0\n    },\n    \"resend_required\": false\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Tax exceeds gross",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"The declared tax of 700,000.00 is greater than the declared gross of 604,210.00. One of the two figures is wrong.\"\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Future date",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Validation failed.\",\n  \"errors\": {\n    \"business_date\": [\n      \"The business date field must be a date before or equal to today.\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Business date is in the till's future",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"That business date is in the future for this till. Its current business date is 2026-07-22 (Africa/Nairobi).\",\n  \"errors\": {\n    \"business_date\": [\n      \"Cannot be later than the till's current business date.\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('accepted', () => pm.expect([200,202]).to.include(pm.response.code));",
                  "const b = pm.response.json();",
                  "pm.test('only genuinely missing documents are flagged for resend', function () {",
                  "    if (b.data && b.data.resend_required) {",
                  "        pm.expect(b.data.breakdown.never_received).to.be.above(0);",
                  "    }",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Read back a day's comparison",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{pos_key_id}}.{{pos_secret}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/pos/daily-summary/2026-07-21",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "pos",
                "daily-summary",
                "2026-07-21"
              ]
            },
            "description": "The stored comparison for a business date, including the bucket breakdown."
          },
          "response": [
            {
              "name": "404 Never reported",
              "status": "Not Found",
              "code": 404,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"No end-of-day summary has been received for 2026-07-21.\"\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "What must I re-send?",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{pos_key_id}}.{{pos_secret}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/pos/daily-summary/2026-07-21/missing",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "pos",
                "daily-summary",
                "2026-07-21",
                "missing"
              ]
            },
            "description": "Returns a list of missing receipt numbers so your POS system can automatically reconcile and resend any unsynced transactions.\n\n`missing_references` is included when sequential numbering (`sequential_references`) is enabled on the integration. Otherwise, the total missing count is reported."
          },
          "response": [
            {
              "name": "200 Four to re-send",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Success\",\n  \"data\": {\n    \"business_date\": \"2026-07-21\",\n    \"never_received_count\": 4,\n    \"missing_references\": [\n      \"RST-8903\",\n      \"RST-8904\",\n      \"RST-8917\",\n      \"RST-8952\"\n    ],\n    \"resend_required\": true,\n    \"note\": \"These receipts never reached us. Re-send them from your POS.\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "200 Nothing to re-send",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Success\",\n  \"data\": {\n    \"business_date\": \"2026-07-21\",\n    \"never_received_count\": 0,\n    \"missing_references\": [],\n    \"resend_required\": false,\n    \"note\": \"Nothing to re-send. Anything still outstanding is with us and is being handled.\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Poll a document by your own reference",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{pos_key_id}}.{{pos_secret}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/pos/documents/RST-8891",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "pos",
                "documents",
                "RST-8891"
              ]
            },
            "description": "The other half of the async contract. Poll with the reference **your** system\nissued - you never have to store our identifiers.\n\nPoll until `status` is one of `accepted`, `rejected`, `cancelled`, or\n`dead_letter`. `on_hold`, `queued` and `retry_scheduled` all mean \"still ours,\nkeep waiting\".\n\nOn a **credit or debit note**, the reply also carries `original_document_ref` (the sale it amends) and `reason_code`; a plain sale omits both.\n\n**Control-unit receipt parts.** When the filing goes DIRECT to KRA VSCU, an accepted reply also carries `sdc_id` (the signing device id), `mrc_no` (its serial) and `internal_data` (the eTIMS internal signature) alongside `kra_control_code` and `verification_url` - everything a compliant printed receipt needs. These keys are simply omitted for providers that do not return them, and for any document not yet filed."
          },
          "response": [
            {
              "name": "200 Accepted",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Success\",\n  \"data\": {\n    \"document_id\": 90001,\n    \"reference\": \"RST-8891\",\n    \"status\": \"accepted\",\n    \"kra_control_code\": \"KRACU00918273645\",\n    \"kra_invoice_number\": \"INV0000090001\",\n    \"verification_url\": \"https://etims.kra.go.ke/verify/KRACU00918273645\",\n    \"total_incl_tax\": 1450.0\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "200 Accepted (direct VSCU - with receipt parts)",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Success\",\n  \"data\": {\n    \"document_id\": 90012,\n    \"reference\": \"RST-8912\",\n    \"status\": \"accepted\",\n    \"kra_control_code\": \"ABCD-EFGH-IJKL-MNOP\",\n    \"kra_invoice_number\": \"48\",\n    \"verification_url\": \"https://itax.kra.go.ke/KRA-Portal/invoiceChk.htm?Data=P051234567X00ABCD-EFGH-IJKL-MNOP\",\n    \"provider_reference\": \"48\",\n    \"sdc_id\": \"SDC0010000001\",\n    \"mrc_no\": \"MRC0100000001\",\n    \"internal_data\": \"WXYZ-1234-5678-90AB\",\n    \"total_incl_tax\": 1450.0\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "200 Accepted (credit note - shows what it amends)",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Success\",\n  \"data\": {\n    \"document_id\": 90008,\n    \"reference\": \"CN-0007\",\n    \"document_type\": \"credit_note\",\n    \"original_document_ref\": \"RST-8891\",\n    \"reason_code\": \"06\",\n    \"status\": \"accepted\",\n    \"kra_control_code\": \"KRACU00918299001\",\n    \"kra_invoice_number\": \"INV0000090008\",\n    \"total_incl_tax\": 650.0\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "200 Retry scheduled",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Success\",\n  \"data\": {\n    \"document_id\": 90001,\n    \"reference\": \"RST-8891\",\n    \"status\": \"retry_scheduled\",\n    \"error_code\": \"UPSTREAM_UNAVAILABLE\",\n    \"error_message\": \"The revenue authority endpoint did not respond.\",\n    \"next_attempt_at\": \"2026-07-21T10:09:02+00:00\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "404 Never received",
              "status": "Not Found",
              "code": 404,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"No document with reference [RST-9999] has been received from this POS integration.\"\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "3. Accounts & users",
      "description": "Manage business accounts and user access. Organization users have access to their specific account, while platform administrators can manage across accounts.",
      "item": [
        {
          "name": "List accounts",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/accounts?per_page=25",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "accounts"
              ],
              "query": [
                {
                  "key": "per_page",
                  "value": "25"
                }
              ]
            },
            "description": "Requires `accounts.view`. A tenant user only ever sees their own account, enforced in the query rather than by filtering afterwards."
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Accounts retrieved.\",\n  \"data\": [\n    {\n      \"id\": 1,\n      \"name\": \"Sunrise Cafe Ltd\",\n      \"kra_pin\": \"P051234567X\",\n      \"status\": \"active\",\n      \"pos_integrations_count\": 2\n    }\n  ],\n  \"pagination\": {\n    \"total\": 1,\n    \"per_page\": 25,\n    \"current_page\": 1,\n    \"last_page\": 1,\n    \"from\": 1,\n    \"to\": 1\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Create account",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/accounts",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "accounts"
              ]
            },
            "description": "Requires `accounts.manage`. The KRA PIN must match `A123456789B` and is unique across the platform.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Sunrise Cafe Ltd\",\n  \"kra_pin\": \"P051234567X\",\n  \"contact_email\": \"accounts@sunrisecafe.co.ke\",\n  \"contact_phone\": \"0722000111\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 Created",
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Account created.\",\n  \"data\": {\n    \"id\": 1,\n    \"name\": \"Sunrise Cafe Ltd\",\n    \"kra_pin\": \"P051234567X\",\n    \"status\": \"active\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Invalid PIN",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Validation failed.\",\n  \"errors\": {\n    \"kra_pin\": [\n      \"The kra pin field format is invalid.\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});",
                  "const acc = pm.response.json();",
                  "if (acc && acc.data && acc.data.id) pm.collectionVariables.set('account_id', acc.data.id);"
                ]
              }
            }
          ]
        },
        {
          "name": "List users",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/users",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "users"
              ]
            },
            "description": "Requires `users.view`."
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Users retrieved.\",\n  \"data\": [\n    {\n      \"id\": 2,\n      \"name\": \"Sunrise Admin\",\n      \"email\": \"admin@sunrisecafe.co.ke\",\n      \"account_id\": 1,\n      \"status\": \"active\",\n      \"roles\": [\n        \"account-admin\"\n      ]\n    }\n  ],\n  \"pagination\": {\n    \"total\": 1,\n    \"per_page\": 25,\n    \"current_page\": 1,\n    \"last_page\": 1,\n    \"from\": 1,\n    \"to\": 1\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Create user",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/users",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "users"
              ]
            },
            "description": "Requires `users.manage`.\n\n**Guards**\n- A tenant admin may only create users inside their own account.\n- A tenant admin may **not** assign a platform role - that is privilege escalation\n  and returns 403.\n- Passwords must be at least 12 characters with letters, numbers and symbols.\n\n**Welcome email.** On success a `NewUserWelcomeMail` is **queued** to the new user with their sign-in URL (`FRONTEND_URL + page-signin.html`), username (email) and the **temporary password** supplied here - the email instructs them to change it on first sign-in.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Jane Wanjiru\",\n  \"email\": \"jane@sunrisecafe.co.ke\",\n  \"password\": \"Str0ng!Passw0rd!\",\n  \"account_id\": 1,\n  \"roles\": [\n    \"account-viewer\"\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 Created",
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"User created.\",\n  \"data\": {\n    \"id\": 5,\n    \"name\": \"Jane Wanjiru\",\n    \"email\": \"jane@sunrisecafe.co.ke\",\n    \"account_id\": 1,\n    \"roles\": [\n      \"account-viewer\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "403 Escalation attempt",
              "status": "Forbidden",
              "code": 403,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Platform roles cannot be assigned by an account user.\",\n  \"errors\": {\n    \"roles\": [\n      \"Contains one or more platform-only roles.\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "List roles and their permissions",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/roles",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "roles"
              ]
            },
            "description": "Requires `users.view`. The complete authorization matrix, as seeded."
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Success\",\n  \"data\": [\n    {\n      \"id\": 1,\n      \"name\": \"platform-admin\",\n      \"is_platform\": true,\n      \"permissions\": [\n        \"accounts.view\",\n        \"accounts.manage\",\n        \"billing.payments.record\"\n      ]\n    }\n  ]\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "4. POS integrations",
      "description": "\nThe centre of gravity of the system. Every credential, mapping, subscription and\ndocument hangs off a POS integration rather than the account - which is what\nmakes *one account, many POS systems, billed separately, different formats* fall\nout naturally.\n",
      "item": [
        {
          "name": "List integrations",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations?per_page=25",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations"
              ],
              "query": [
                {
                  "key": "per_page",
                  "value": "25"
                }
              ]
            },
            "description": "Requires `integrations.view`. Each row carries `may_submit`, which answers 'is this till currently filing?' in one field."
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"POS integrations retrieved.\",\n  \"data\": [\n    {\n      \"id\": 10,\n      \"account_id\": 1,\n      \"name\": \"Sunrise Restaurant Till\",\n      \"code\": \"sunrise-restaurant\",\n      \"branch_code\": \"00\",\n      \"provider\": \"thirdparty\",\n      \"result_delivery_mode\": \"webhook\",\n      \"webhook_url\": \"https://sunrisecafe.co.ke/hooks/kra\",\n      \"status\": \"active\",\n      \"accepts_ingestion\": true,\n      \"may_submit\": true,\n      \"subscription\": {\n        \"id\": 3,\n        \"status\": \"active\",\n        \"price\": 3000.0,\n        \"currency\": \"KES\",\n        \"interval\": \"month\",\n        \"interval_count\": 1,\n        \"paid_through_at\": \"2026-07-31\",\n        \"grace_days\": 7,\n        \"days_until_expiry\": 10\n      }\n    }\n  ],\n  \"pagination\": {\n    \"total\": 1,\n    \"per_page\": 25,\n    \"current_page\": 1,\n    \"last_page\": 1,\n    \"from\": 1,\n    \"to\": 1\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const b = pm.response.json();",
                  "if (b.data && b.data.length) { pm.collectionVariables.set('integration_id', b.data[0].id); }",
                  "pm.test('ok', () => pm.response.to.have.status(200));"
                ]
              }
            }
          ]
        },
        {
          "name": "Create integration",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations"
              ]
            },
            "description": "Requires `integrations.manage`.\n\nCreates the integration **and its subscription** in one transaction: an\nintegration with no subscription would ingest but never submit, which is a\nconfiguration trap rather than a useful state.\n\n**Terms are snapshot** from the plan at this moment. Raising the plan price later\ndoes not reprice this customer.\n\n- `code` - unique within the account, `[A-Za-z0-9_-]`.\n- `provider` - the fiscalisation aggregator this till files through: `digitax`, `advatech` (and the internal `mock`/`thirdparty`). One provider per till. Direct KRA is planned.\n- `auth_scheme` - how this till's POS authenticates *inbound*: `bearer` (default; a till key in the header) or `sender_id` (a secret in the JSON body at `Invoice.SenderId`, for a POS that cannot send headers). Distinct from `provider`, which is *outbound*. A `sender_id` till files at `/v1/pos/ingest` and needs a registered SenderId.\n- `result_delivery_mode` - `async` (poll), `webhook` (we call you), `sync` (we hold the request).\n- `webhook_url` is required when the mode is `webhook`; a signing secret is minted automatically.\n- `trial_periods` - optional free periods granted at onboarding.\n\n**A real aggregator files under the merchant's OWN credentials** (tied to their\nKRA PIN). So a till cannot be pointed at `digitax` or `advatech` until that\naggregator key is connected for the account - otherwise this returns 422.\n\n**Callback URLs are checked, not just parsed.** We resolve the host and refuse\nanything on a private, loopback, link-local or otherwise reserved network -\nincluding `169.254.169.254`, `127.0.0.1`, `10.x`, `192.168.x` and their IPv6\nequivalents. Only `http` and `https`, and no credentials in the URL.\n\nThe same check runs again immediately before every callback, so a hostname that\nresolves publicly today and privately tomorrow is still refused. Redirects are\nnot followed.\n\n**Save-time pre-flight normalization.** When you set a `webhook_url` we send one `HEAD` request (redirects NOT followed) and rewrite the stored URL only when the endpoint answers `301`/`302` with a `Location` that itself passes the SSRF guard - normalizing `http`→`https` or apex→`www` once, up front. A `200`/`404`/`405`, a timeout, or an unreachable host leaves the URL exactly as you sent it: pre-flight never blocks the save. It will NOT chase a redirect that merely appends a trailing slash to a file endpoint (`/callback.php` → `/callback.php/`), because that slash 404s a PHP/JSP/ASP/Python handler. When the stored URL is rewritten, the response carries `webhook_url_normalized: { from, to, reason }`.\n\n- `routing_mode` - how document types are resolved at ingestion. `prescribed` (default): typed URL (`/documents/type/{type}`), `X-Document-Type` header, body `document_type` key, or subpath keyword. `custom`: additionally matches an **un-typed single-endpoint** payload against this integration's active mappings before defaulting to `sales_invoice` - for legacy tills that post every receipt to one endpoint.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"account_id\": 1,\n  \"name\": \"Sunrise Restaurant Till\",\n  \"code\": \"sunrise-restaurant\",\n  \"branch_code\": \"00\",\n  \"provider\": \"digitax\",\n  \"routing_mode\": \"prescribed\",\n  \"auth_scheme\": \"bearer\",\n  \"result_delivery_mode\": \"webhook\",\n  \"webhook_url\": \"https://sunrisecafe.co.ke/hooks/kra\",\n  \"billing_plan_code\": \"standard_monthly\",\n  \"grace_days\": 7,\n  \"trial_periods\": 1\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 Created",
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"POS integration created.\",\n  \"data\": {\n    \"id\": 10,\n    \"account_id\": 1,\n    \"name\": \"Sunrise Restaurant Till\",\n    \"code\": \"sunrise-restaurant\",\n    \"provider\": \"digitax\",\n    \"result_delivery_mode\": \"webhook\",\n    \"status\": \"active\",\n    \"may_submit\": true,\n    \"subscription\": {\n      \"status\": \"active\",\n      \"price\": 3000.0,\n      \"paid_through_at\": \"2026-08-20\"\n    },\n    \"routing_mode\": \"prescribed\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Aggregator not connected",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Connect a digitax key for this account before pointing an integration at it. A filing is made under the merchant’s own aggregator credentials.\",\n  \"errors\": {\n    \"provider\": [\n      \"No active digitax credentials are on file for this account.\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Duplicate code",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"This account already has a POS integration with the code [sunrise-restaurant]. Codes must be unique within an account.\",\n  \"errors\": {\n    \"code\": [\n      \"Already in use for this account.\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Callback URL points inside a private network",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Validation failed.\",\n  \"errors\": {\n    \"webhook_url\": [\n      \"That host resolves to an address on a private or reserved network, which we will not send callbacks to.\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Get integration",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}"
              ]
            },
            "description": "Requires `integrations.view`. The webhook signing secret is never returned."
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Success\",\n  \"data\": {\n    \"id\": 10,\n    \"code\": \"sunrise-restaurant\",\n    \"status\": \"active\",\n    \"may_submit\": true,\n    \"provider\": \"digitax\",\n    \"provider_status\": {\n      \"selected\": \"digitax\",\n      \"label\": \"Digitax\",\n      \"requires_credentials\": true,\n      \"credentials_connected\": true,\n      \"ready_to_file\": true\n    }\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Update integration",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}"
              ]
            },
            "description": "Requires `integrations.manage`. Switching `provider` here migrates the till between aggregators (and, later, to direct KRA) - one integration at a time.\n\n**Switching the aggregator is bound-for-life per document.** A sale half-filed on the old aggregator must never be re-driven on the new one (a duplicate filing). So the switch is refused (422) while ANY document is still in flight - queued, submitting, retrying or held - and it only ever applies to NEW documents; history stays with the aggregator that filed it. Switching TO a real aggregator also requires its credentials on file (as with create).\n\n**Callback URLs are checked, not just parsed.** We resolve the host and refuse\nanything on a private, loopback, link-local or otherwise reserved network -\nincluding `169.254.169.254`, `127.0.0.1`, `10.x`, `192.168.x` and their IPv6\nequivalents. Only `http` and `https`, and no credentials in the URL.\n\nThe same check runs again immediately before every callback, so a hostname that\nresolves publicly today and privately tomorrow is still refused. Redirects are\nnot followed.\n\n**Save-time pre-flight normalization.** When you set a `webhook_url` we send one `HEAD` request (redirects NOT followed) and rewrite the stored URL only when the endpoint answers `301`/`302` with a `Location` that itself passes the SSRF guard - normalizing `http`→`https` or apex→`www` once, up front. A `200`/`404`/`405`, a timeout, or an unreachable host leaves the URL exactly as you sent it: pre-flight never blocks the save. It will NOT chase a redirect that merely appends a trailing slash to a file endpoint (`/callback.php` → `/callback.php/`), because that slash 404s a PHP/JSP/ASP/Python handler. When the stored URL is rewritten, the response carries `webhook_url_normalized: { from, to, reason }`.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Sunrise Restaurant Till (Counter 1)\",\n  \"result_delivery_mode\": \"async\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"POS integration updated.\",\n  \"data\": {\n    \"id\": 10,\n    \"result_delivery_mode\": \"async\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Callback URL points inside a private network",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Validation failed.\",\n  \"errors\": {\n    \"webhook_url\": [\n      \"That host resolves to an address on a private or reserved network, which we will not send callbacks to.\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Cannot switch aggregator while in flight",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"This integration has 3 document(s) still in flight on [advatech]. Let them finish (or cancel them) before switching aggregator, so nothing is filed twice.\",\n  \"errors\": {\n    \"provider\": [\n      \"Cannot switch while documents are queued, retrying, submitting or held.\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Pause integration",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/pause",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "pause"
              ]
            },
            "description": "Requires `integrations.manage`. **Not** a data-loss operation: payloads are still accepted and stored, they simply hold. Use this while investigating a mapping problem."
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Integration paused. Payloads are still accepted and stored; nothing will be submitted until it is resumed.\",\n  \"data\": {\n    \"id\": 10,\n    \"status\": \"paused\",\n    \"may_submit\": false\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Resume integration",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/resume",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "resume"
              ]
            },
            "description": "Requires `integrations.manage`. Releases the held backlog in issue order, throttled to the configured batch size."
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Integration resumed. 412 held document(s) released for submission.\",\n  \"data\": {\n    \"id\": 10,\n    \"status\": \"active\",\n    \"may_submit\": true,\n    \"released_documents\": 412\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "View monthly allowance and usage",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/quota",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "quota"
              ]
            },
            "description": "Requires `billing.view`.\n\n**A speedometer, not a limiter.** `enforcement_enabled` is false: documents are\nnever blocked, however far past the allowance a till goes. The number exists so\nwe can warn in good time and start a conversation about a bigger plan."
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Success\",\n  \"data\": {\n    \"pos_integration_id\": 10,\n    \"month\": \"2026-07\",\n    \"limit\": 5000,\n    \"limit_source\": \"plan\",\n    \"used\": 4210,\n    \"remaining\": 790,\n    \"percent\": 84,\n    \"over\": false,\n    \"enforcement_enabled\": false,\n    \"behaviour\": \"Warnings only. Documents are never blocked.\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Set the monthly allowance",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/quota",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "quota"
              ]
            },
            "description": "Requires `billing.quota.manage` - platform staff only. **A merchant can never\nraise their own allowance**; one they could raise would not be an allowance.\nEvery change is audited.\n\n- a number → this till's own allowance, overriding its plan\n- `null` → inherit the plan's default\n- `0` → unlimited for this till",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"monthly_document_quota\": 25000\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Updated",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Allowance updated. This changes when we warn you \\u2014 it does not stop any documents.\",\n  \"data\": {\n    \"pos_integration_id\": 10,\n    \"limit\": 25000,\n    \"limit_source\": \"integration\",\n    \"used\": 4210,\n    \"percent\": 16,\n    \"enforcement_enabled\": false\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "403 Merchant raising their own",
              "status": "Forbidden",
              "code": 403,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"You do not have permission to perform this action.\",\n  \"errors\": {\n    \"required_permissions\": [\n      \"billing.quota.manage\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Rotate webhook signing secret",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/webhook-secret",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "webhook-secret"
              ]
            },
            "description": "Requires `integrations.manage`.\n\nRotating a signing secret **without an overlap is a guaranteed outage**: we would\nstart signing with a secret you do not have yet, and every callback would fail\nverification until you redeployed. So for 24 hours we sign with **both**, and the\nheader carries two `v1` values:\n\n```\nX-KRA-Signature: t=1753000000,v1=<new secret>,v1=<previous secret>\n```\n\n**Accept the callback if either matches.** Deploy the new secret at your own\npace, then drop the old one.\n\nPass `immediate: true` to skip the overlap - for a genuinely leaked secret, where\nbreaking callbacks is better than leaving the leak live.\n\nThe new secret is shown once and never again.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"immediate\": false\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Rotated with overlap",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Webhook signing secret rotated. The previous secret stays valid until Wed, 22 Jul 2026 14:30, so you can deploy without downtime. Store the new secret now \\u2014 it will not be shown again.\",\n  \"data\": {\n    \"webhook_secret\": \"9f2b7c4e1a8d3f60b5c9e2a7d4f18b03c6e5a29d7b4f1c80\",\n    \"previous_secret_valid_until\": \"2026-07-22T14:30:00+00:00\",\n    \"signature_note\": \"Until the date above, callbacks carry TWO v1 signatures \\u2014 the new secret and the old. Accept the callback if either matches, then remove the old secret.\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "200 Immediate (leaked secret)",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Webhook signing secret rotated immediately. Store it now \\u2014 it will not be shown again.\",\n  \"data\": {\n    \"webhook_secret\": \"1a2b3c...\",\n    \"previous_secret_valid_until\": null,\n    \"signature_note\": \"Callbacks are signed with the new secret only. Any endpoint still using the old one will reject them.\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 No callback configured",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"This integration has no callback URL, so it has no signing secret to rotate.\"\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Provider selection",
      "description": "Choosing which KRA aggregator a POS integration files through - and connecting the merchant's key for it.\n\n**The flow:** *discover* the options (`GET /providers`) → *connect* the merchant's aggregator key (`POST /accounts/{id}/provider-credentials`) → *select* it on the integration (set `provider` on create/update) → confirm the integration's `provider_status.ready_to_file`.\n\nA real aggregator files under the **merchant's own** credentials (tied to their KRA PIN), so the key is per account, per provider, per environment. The `api_key` is **write-only**: stored encrypted, hidden on the model, never returned.",
      "item": [
        {
          "name": "List selectable providers",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/providers",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "providers"
              ]
            },
            "description": "Requires `integrations.view`. The catalogue a UI uses to present the aggregator choice. `slug` is the value you put in the integration's `provider` field; `label` is display text and may change. Internal providers (mock/thirdparty) are never listed."
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Selectable fiscalisation providers.\",\n  \"data\": [\n    {\n      \"slug\": \"digitax\",\n      \"label\": \"Digitax\",\n      \"requires_credentials\": true,\n      \"document_types\": [\"sales_invoice\", \"credit_note\"],\n      \"environments\": [\"sandbox\", \"production\"]\n    },\n    {\n      \"slug\": \"advatech\",\n      \"label\": \"Advatech\",\n      \"requires_credentials\": true,\n      \"document_types\": [\"sales_invoice\", \"credit_note\"],\n      \"environments\": [\"sandbox\", \"production\"]\n    },\n    {\n      \"slug\": \"vscu\",\n      \"label\": \"KRA VSCU (Direct)\",\n      \"requires_credentials\": true,\n      \"document_types\": [\"sales_invoice\", \"credit_note\"],\n      \"environments\": [\"sandbox\", \"production\"]\n    }\n  ]\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ]
        },
        {
          "name": "Connect a provider key",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/accounts/1/provider-credentials",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "accounts",
                "1",
                "provider-credentials"
              ]
            },
            "description": "Requires `integrations.manage`. Connects the merchant's aggregator key so an integration can be pointed at that provider.\n\n- `provider` must be a selectable aggregator (`digitax`, `advatech`).\n- `api_key` is **write-only** - it is stored encrypted and never returned. Digitax authenticates with it as the `X-API-Key` header on every filing.\n- `base_url` **must be Digitax's full server URL including the path prefix - `https://api.digitax.tech/ke/v2`.** Omitting `/ke/v2` files against the bare host and every sale comes back `404 Not Found`. There is no separate sandbox host: sandbox vs production is decided by the key, not the URL.\n- `default_item_class_code` - the KRA item classification code (an **8-digit** code, e.g. `50000000` for food/beverage) used for any sale line that does not carry its own. Digitax **rejects a sale with no class code**, so set this unless every line supplies one.\n- `base_url` is resolved and refused if it points at a private/reserved network - the same SSRF guard as callbacks.\n- Only **one active key** per provider+environment; rotate or revoke to replace it.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"provider\": \"digitax\",\n  \"environment\": \"sandbox\",\n  \"label\": \"Sunrise Digitax\",\n  \"api_key\": \"dgx_live_secret_key_value\",\n  \"business_id\": \"biz-77\",\n  \"default_item_class_code\": \"50000000\",\n  \"base_url\": \"https://api.digitax.tech/ke/v2\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 Connected (secret never echoed)",
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Provider credentials connected.\",\n  \"data\": {\n    \"id\": 5,\n    \"account_id\": 1,\n    \"provider\": \"digitax\",\n    \"environment\": \"sandbox\",\n    \"label\": \"Sunrise Digitax\",\n    \"status\": \"active\",\n    \"has_api_key\": true,\n    \"identifiers\": {\n      \"business_id\": \"biz-77\",\n      \"default_item_class_code\": \"50000000\",\n      \"base_url\": \"https://api.digitax.tech/ke/v2\"\n    }\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "409 Already connected",
              "status": "Conflict",
              "code": 409,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"This account already has an active digitax (sandbox) key. Update it to rotate the secret, or revoke it first.\"\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Base URL points inside a private network",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"That base URL resolves to a private or reserved network and will not be used for filing.\",\n  \"errors\": {\n    \"base_url\": [\n      \"Points at a blocked destination.\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ]
        },
        {
          "name": "Connect a provider key (Advatech)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/accounts/1/provider-credentials",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "accounts",
                "1",
                "provider-credentials"
              ]
            },
            "description": "The Advatech variant of *Connect a provider key*. Advatech files raw eTIMS under the merchant's own KRA PIN, so its key carries more identifiers than Digitax:\n\n- `branch_id` - the aggregator's branch UUID (not the KRA `bhfId`).\n- `tin` - the merchant's KRA PIN.\n- `default_item_class_code` - the KRA item classification used when a product does not state its own.\n- `address` - the merchant's trading address. It is **printed on the KRA receipt** (`adrs`); if it is omitted the sale still files, but the receipt shows a placeholder (`N/A`), so set the real address once.\n\nAs with Digitax, `api_key` is **write-only** - stored encrypted, never returned - and only **one active key** is allowed per provider + environment.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"provider\": \"advatech\",\n  \"environment\": \"production\",\n  \"label\": \"Sunrise Advatech\",\n  \"api_key\": \"etims_live_secret_key_value\",\n  \"base_url\": \"https://etims-sbx.advatechintegrations.com\",\n  \"branch_id\": \"0041b214-5418-4402-888e-273927947a11\",\n  \"tin\": \"P051738747X\",\n  \"default_item_class_code\": \"5020230100\",\n  \"address\": \"Nairobi, Kenya\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Connect a provider key (VSCU Direct)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/accounts/1/provider-credentials",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "accounts",
                "1",
                "provider-credentials"
              ]
            },
            "description": "KRA **VSCU** is the DIRECT-to-KRA provider (Virtual Sales Control Unit) - a LOCAL component running on the merchant's own box, so it is unlike the aggregators:\n\n- **No `api_key`/bearer.** Auth is `tin` + `bhf_id` (the device `cmcKey` is read by the VSCU service from its own disk). `cmckey` is **optional** - the sandbox needs none; supply it only if your production device requires it.\n- **`base_url` is a trusted local endpoint** (e.g. `http://localhost:8088`). Because VSCU is local by design, its base URL is deliberately NOT run through the private/loopback SSRF guard that protects aggregator URLs.\n- **`bhf_id`** is the KRA branch id (e.g. `06`), and `default_item_class_code` is used when a product does not state its own.\n- The Agent **assigns the invoice number** itself (VSCU does not), and a resubmit reuses it - a duplicate is answered with `924` and treated as already-filed, never double-filed.\n\nAfter connecting, set the integration's `provider` to `vscu`; products register (`/items/register`) and sales file (`/v1/pos/documents`) exactly as for any other provider. Direct VSCU only works when the Agent runs on the same box as the merchant's VSCU service.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"provider\": \"vscu\",\n  \"environment\": \"production\",\n  \"label\": \"Sunrise VSCU\",\n  \"base_url\": \"http://localhost:8088\",\n  \"tin\": \"P051738747X\",\n  \"bhf_id\": \"06\",\n  \"default_item_class_code\": \"5059690800\",\n  \"trade_name\": \"Sunrise Cafe Ltd\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "List connected keys",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/accounts/1/provider-credentials",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "accounts",
                "1",
                "provider-credentials"
              ]
            },
            "description": "Requires `integrations.view`. Lists the account's connected aggregator keys. Secrets are never included - only `has_api_key` and the non-secret identifiers."
          },
          "response": []
        },
        {
          "name": "Rotate / update a key",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/accounts/1/provider-credentials/5",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "accounts",
                "1",
                "provider-credentials",
                "5"
              ]
            },
            "description": "Requires `integrations.manage`. Send a new `api_key` to rotate the secret (in place), and/or `label`/`status`. Any identifier fields sent are merged over the stored blob. The new secret is not echoed.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"api_key\": \"dgx_rotated_new_secret\",\n  \"label\": \"Sunrise Digitax (rotated)\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Revoke a key",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/accounts/1/provider-credentials/5",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "accounts",
                "1",
                "provider-credentials",
                "5"
              ]
            },
            "description": "Requires `integrations.manage`. Soft-revokes the key (kept for audit). Integrations still on this provider hold their documents until a key is reconnected - nothing is lost. Revoking frees the one-active-per-provider slot so a fresh key can be connected."
          },
          "response": [
            {
              "name": "200 Revoked",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Provider credentials revoked. Integrations on this provider will hold documents until a key is reconnected.\",\n  \"data\": {\n    \"id\": 5,\n    \"provider\": \"digitax\",\n    \"status\": \"revoked\",\n    \"has_api_key\": true\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ]
        }
      ]
    },
    {
      "name": "5. API credentials",
      "description": "API credentials for your POS tills. Multiple keys can remain active at once so you can rotate keys smoothly without downtime.\n\nSupports two authentication schemes: **Bearer keys** (`key_id` + `secret`) in the Authorization header, or **SenderId** credentials sent inside the request body.",
      "item": [
        {
          "name": "Register a SenderId (body-auth till)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/sender-credentials",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "sender-credentials"
              ]
            },
            "description": "Requires `credentials.manage`. Registers the **provider-generated** `SenderId` (the random secret the POS puts at `Invoice.SenderId`) against this till. We store only its hash - it is **write-only** and never echoed back. One SenderId maps to one credential; a duplicate is a `409`. Revoke to retire it. Only meaningful for a till whose `auth_scheme` is `sender_id`.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"sender_id\": \"3c0e19f2f890ea7ca15e\",\n  \"label\": \"Front counter\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 Registered",
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"SenderId registered.\",\n  \"data\": { \"id\": 12, \"label\": \"Front counter\", \"usable\": true, \"revoked_at\": null }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Wrong scheme (till is not sender_id)",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"This till uses the [bearer] authentication scheme, so a SenderId would never authenticate. Create a bearer key at POST /v1/integrations/10/credentials instead, or switch the till's auth_scheme to sender_id first.\",\n  \"errors\": { \"auth_scheme\": [ \"A SenderId can only be registered for a sender_id-scheme till.\" ] }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "protocolProfileBehavior": {},
          "event": []
        },
        {
          "name": "Revoke a SenderId",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/sender-credentials/{{sender_credential_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "sender-credentials",
                "{{sender_credential_id}}"
              ]
            },
            "description": "Requires `credentials.manage`. Revokes a registered SenderId; it stops authenticating immediately. Because the secret is stored (not redacted), revocation is the primary way to retire a leaked one - register a fresh SenderId and revoke the old."
          },
          "response": []
        },
        {
          "name": "List credentials",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/credentials",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "credentials"
              ]
            },
            "description": "Requires `credentials.view`. Metadata only - the secret and its hash are never returned."
          },
          "response": [
            {
              "name": "200 Mid-rotation",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Success\",\n  \"data\": [\n    {\n      \"id\": 9,\n      \"key_id\": \"kra_n3wk3yh3re000000000000\",\n      \"label\": \"Front counter (rotated)\",\n      \"last_used_at\": \"2026-07-21T14:35:00+00:00\",\n      \"expires_at\": null,\n      \"revoked_at\": null,\n      \"usable\": true,\n      \"retiring\": false,\n      \"hours_until_expiry\": null,\n      \"superseded_by_id\": null,\n      \"safe_to_revoke\": false\n    },\n    {\n      \"id\": 5,\n      \"key_id\": \"kra_x8v9zjglzknr3sniwflk\",\n      \"label\": \"Front counter\",\n      \"last_used_at\": \"2026-07-21T14:22:00+00:00\",\n      \"expires_at\": \"2026-07-22T14:30:00+00:00\",\n      \"revoked_at\": null,\n      \"usable\": true,\n      \"retiring\": true,\n      \"hours_until_expiry\": 23.9,\n      \"superseded_by_id\": 9,\n      \"safe_to_revoke\": true\n    }\n  ]\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Create credential",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/credentials",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "credentials"
              ]
            },
            "description": "Requires `credentials.manage`.\n\nThe secret is returned **once** and is stored only as a hash - exactly like a\npassword. If it is lost, mint a new one and revoke the old.\n\n`authorization_header` is provided ready to paste into the till's configuration.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"label\": \"Front counter till\",\n  \"expires_at\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 Created",
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"API credential created. Copy the secret now \\u2014 it will not be shown again.\",\n  \"data\": {\n    \"id\": 5,\n    \"key_id\": \"kra_x8v9zjglzknr3sniwflk\",\n    \"secret\": \"sk_ILdUug8GyNunXOIecHkb3XdBoj6brNT03UmmMWGuLAR304El\",\n    \"authorization_header\": \"Bearer kra_x8v9zjglzknr3sniwflk.sk_ILdUug8GyNunXOIecHkb3XdBoj6brNT03UmmMWGuLAR304El\",\n    \"label\": \"Front counter till\",\n    \"expires_at\": null\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Wrong scheme (till is sender_id)",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"This till uses the [sender_id] authentication scheme, so a bearer key would never authenticate. Register a SenderId at POST /v1/integrations/10/sender-credentials instead.\",\n  \"errors\": { \"auth_scheme\": [ \"A bearer key can only be issued for a bearer-scheme till.\" ] }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const b = pm.response.json();",
                  "if (b.success) {",
                  "  pm.collectionVariables.set('pos_key_id', b.data.key_id);",
                  "  pm.collectionVariables.set('pos_secret', b.data.secret);",
                  "}",
                  "pm.test('created', () => pm.response.to.have.status(201));"
                ]
              }
            }
          ]
        },
        {
          "name": "Rotate credential (zero downtime)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/credentials/5/rotate",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "credentials",
                "5",
                "rotate"
              ]
            },
            "description": "Requires `credentials.manage`.\n\nMints a replacement **and** schedules the old key's death in one operation. A\nmerchant with three physical tills has to walk to each one: revoking first stops\nall three trading until the last is updated, and minting first without revoking\nleaves a key live forever. This does both halves, so nobody has to remember\nanything.\n\n- `grace_hours` - how long the old key keeps working. Defaults to 24.\n- `grace_hours: 0` - kill it immediately. For a genuine leak, where an outage is\n  the lesser evil.\n\nThe listing then reports `retiring`, `hours_until_expiry` and `safe_to_revoke`\n(true once nothing has used the old key since the rotation), so revoking early is\nan informed choice rather than a guess. If a till is *still* using the old key as\nthe window closes, we email the merchant before it stops working.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"grace_hours\": 24,\n  \"label\": \"Front counter (rotated)\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 Rotated",
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"New API credential created. The old key keeps working until Wed, 22 Jul 2026 14:30, so you can update your tills one at a time with no downtime. Copy the secret now \\u2014 it will not be shown again.\",\n  \"data\": {\n    \"id\": 9,\n    \"key_id\": \"kra_n3wk3yh3re000000000000\",\n    \"secret\": \"sk_ZqR7...\",\n    \"authorization_header\": \"Bearer kra_n3wk3yh3re000000000000.sk_ZqR7...\",\n    \"label\": \"Front counter (rotated)\",\n    \"replaces\": {\n      \"id\": 5,\n      \"key_id\": \"kra_x8v9zjglzknr3sniwflk\",\n      \"stops_working_at\": \"2026-07-22T14:30:00+00:00\",\n      \"last_used_at\": \"2026-07-21T14:22:00+00:00\"\n    }\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "201 Immediate (leaked key)",
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"New API credential created and the old key stopped working immediately. Any till still using it will be rejected until you update it. Copy the secret now \\u2014 it will not be shown again.\",\n  \"data\": {\n    \"id\": 10,\n    \"key_id\": \"kra_emergency00000000000000\",\n    \"replaces\": {\n      \"id\": 5,\n      \"stops_working_at\": \"2026-07-21T14:30:00+00:00\"\n    }\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Already revoked",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"This credential is already revoked or expired, so there is nothing to roll over from. Create a new credential instead.\"\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('created', () => pm.response.to.have.status(201));",
                  "const b = pm.response.json();",
                  "if (b.success) {",
                  "  pm.collectionVariables.set('pos_key_id', b.data.key_id);",
                  "  pm.collectionVariables.set('pos_secret', b.data.secret);",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "Revoke credential",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/credentials/5",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "credentials",
                "5"
              ]
            },
            "description": "Requires `credentials.manage`. Takes effect immediately on the next request."
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"API credential revoked. It will stop working immediately.\",\n  \"data\": {\n    \"id\": 5,\n    \"revoked_at\": \"2026-07-21T11:00:00+00:00\",\n    \"usable\": false\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Already revoked",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"This credential was already revoked on 2026-07-20.\"\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "6. Payload & code mappings",
      "description": "\nHow a POS's private JSON becomes a canonical document - held as **data**, so\nonboarding the twelfth POS format is a row insert, not a deploy.\n\n**Rule shape**\n\n```jsonc\n{\n  \"document\": {\n    \"source_document_ref\": \"receipt.no\",                          // dot path\n    \"issued_at\": {\"path\": \"receipt.issued\", \"format\": \"iso8601\"}, // or \"d/m/Y\"\n    \"total_incl_tax\": {\"path\": \"totals.gross\", \"type\": \"decimal\"}\n  },\n  \"lines\": {\n    \"path\": \"items\",\n    \"fields\": {\n      \"description\": \"desc\",\n      \"quantity\":   {\"path\": \"qty\", \"type\": \"decimal\", \"default\": 1},\n      \"unit_price\": {\"path\": \"price\", \"type\": \"decimal\"},\n      \"source_tax_code\": \"vat\"\n    }\n  },\n  \"options\": {\"amounts_include_tax\": true, \"total_tolerance\": 0.01}\n}\n```\n\nA field spec is either a dot-path string or an object with\n`{path, default, required, type, format, transform}`.\n`type`: `string|decimal|integer|boolean|datetime`.\n`transform`: `upper|lower|trim|digits`.\n",
      "item": [
        {
          "name": "List mapping versions",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/mappings",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "mappings"
              ]
            },
            "description": "Requires `mappings.view`. Versions are never edited - a document already filed keeps pointing at the rules that produced it."
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Success\",\n  \"data\": [\n    {\n      \"id\": 31,\n      \"document_type\": \"sales_invoice\",\n      \"version\": 2,\n      \"is_active\": true,\n      \"notes\": \"Added customer PIN\",\n      \"rules\": {\n        \"document\": {\n          \"source_document_ref\": \"receipt.no\"\n        }\n      }\n    }\n  ]\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Suggest a mapping from a sample",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/mappings/suggest",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "mappings",
                "suggest"
              ]
            },
            "description": "Requires `mappings.view`.\n\nPaste a sample of your POS system's JSON receipt to generate a draft mapping ruleset for review. This endpoint does not save or activate anything—it simply helps you build your configuration quickly.\n\nThe response organizes fields into three review categories:\n\n| Category | Meaning |\n|---|---|\n| `confident` | High-confidence field mappings detected automatically |\n| `confirm` | Best guesses with alternatives for fields that need verification (such as the invoice reference) |\n| `supply` | Values that require your configuration (such as KRA tax codes or timezone) |\n\n**Automatic pricing detection:** The system analyzes your sample invoice totals against both tax-inclusive and tax-exclusive calculations to determine the correct pricing model.\n\nOnce reviewed, send `proposed_rules` to **/mappings/test** to preview the computed invoice, and to **/mappings** to activate.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"document_type\": \"sales_invoice\",\n  \"payload\": {\n    \"Invoice\": {\n      \"SenderId\": \"3c0e19f2f890ea7ca15e\",\n      \"InvoiceTimestamp\": \"2024-03-16T20:47:49\",\n      \"TraderSystemInvoiceNumber\": \"REC-57\",\n      \"RelevantInvoiceNumber\": \"\",\n      \"PINOfBuyer\": \"\",\n      \"TotalInvoiceAmount\": 3047.48,\n      \"TotalTaxableAmount\": 2627.13,\n      \"TotalTaxAmount\": 420.35,\n      \"ItemDetails\": [\n        {\n          \"HSDesc\": \"Strawberry Milkshake\",\n          \"TaxRate\": 16,\n          \"ItemAmount\": 381.3559,\n          \"TaxAmount\": 61.0169,\n          \"UnitPrice\": 381.3559,\n          \"HSCode\": \"\",\n          \"Quantity\": 1\n        },\n        {\n          \"HSDesc\": \"Beef Burger\",\n          \"TaxRate\": 16,\n          \"ItemAmount\": 1694.9152,\n          \"TaxAmount\": 271.1864,\n          \"UnitPrice\": 847.4576,\n          \"HSCode\": \"\",\n          \"Quantity\": 2\n        },\n        {\n          \"HSDesc\": \"BBQ Wings\",\n          \"TaxRate\": 16,\n          \"ItemAmount\": 550.8475,\n          \"TaxAmount\": 88.1356,\n          \"UnitPrice\": 550.8475,\n          \"HSCode\": \"\",\n          \"Quantity\": 1\n        }\n      ]\n    }\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Draft proposed",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Draft mapping proposed. Nothing has been stored \\u2014 review it, dry-run it, then activate.\",\n  \"data\": {\n    \"document_type\": \"sales_invoice\",\n    \"confidence\": \"medium\",\n    \"proposed_rules\": {\n      \"document\": {\n        \"source_document_ref\": \"Invoice.TraderSystemInvoiceNumber\",\n        \"issued_at\": {\n          \"path\": \"Invoice.InvoiceTimestamp\",\n          \"format\": \"iso8601\"\n        },\n        \"customer_pin\": \"Invoice.PINOfBuyer\"\n      },\n      \"lines\": {\n        \"path\": \"Invoice.ItemDetails\",\n        \"fields\": {\n          \"description\": \"HSDesc\",\n          \"quantity\": {\n            \"path\": \"Quantity\",\n            \"type\": \"decimal\"\n          },\n          \"unit_price\": {\n            \"path\": \"UnitPrice\",\n            \"type\": \"decimal\"\n          },\n          \"source_item_code\": \"HSCode\",\n          \"source_tax_code\": \"TaxRate\"\n        }\n      },\n      \"options\": {\n        \"amounts_include_tax\": false\n      }\n    },\n    \"review\": {\n      \"confident\": [\n        {\n          \"field\": \"lines.quantity\",\n          \"path\": \"Quantity\",\n          \"reason\": \"Matched a known field name.\"\n        }\n      ],\n      \"confirm\": [\n        {\n          \"field\": \"source_document_ref\",\n          \"proposed_path\": \"Invoice.TraderSystemInvoiceNumber\",\n          \"alternatives\": [\n            \"Invoice.SenderId\"\n          ],\n          \"reason\": \"Best guess for the invoice number. Confirm it is not a device or sender id.\"\n        }\n      ],\n      \"supply\": [\n        {\n          \"what\": \"tax_code_mappings\",\n          \"detail\": \"Tax values seen: [16]. Map each to a KRA tax code and rate \\u2014 the meaning cannot be inferred. Note: two different codes can both be 0% (exempt vs zero-rated).\"\n        },\n        {\n          \"what\": \"item_codes\",\n          \"detail\": \"The item-code field is present but empty on every line. If KRA requires item classification, map descriptions to codes or confirm the aggregator supplies them.\"\n        },\n        {\n          \"what\": \"timezone\",\n          \"detail\": \"The timestamp carries no timezone. Confirm the till's timezone (e.g. Africa/Nairobi) on the integration, or a late-night sale will land on the wrong business day.\"\n        }\n      ]\n    },\n    \"pricing\": {\n      \"amounts_include_tax\": false,\n      \"method\": \"per_line\",\n      \"derivation\": \"Compared quantity \\u00d7 unit price against each line's declared amount: 3 line(s) match tax-exclusive pricing, 0 match the other. Chose exclusive.\",\n      \"confident\": true\n    },\n    \"codes_to_map\": {\n      \"tax\": [\n        \"16\"\n      ],\n      \"item\": []\n    },\n    \"next_steps\": \"Review the trays, then POST proposed_rules to /integrations/{id}/mappings/test to preview the computed invoice, and /integrations/{id}/mappings to activate. Nothing has been stored.\"\n  }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Empty payload",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Validation failed.\",\n  \"errors\": {\n    \"payload\": [\n      \"The payload field is required.\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "413 Sample too large",
              "status": "Content Too Large",
              "code": 413,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Sample payload is too large.\"\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "200 Trays (auto_suggested / missing_required)",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Draft mapping proposed. Nothing has been stored \\u2014 review it, dry-run it, then activate.\",\n  \"data\": {\n    \"document_type\": \"credit_note\",\n    \"proposed_rules\": {\n      \"document\": {\n        \"source_document_ref\": {\n          \"path\": \"InvoiceNumber\"\n        },\n        \"issued_at\": {\n          \"path\": \"InvoiceTimestamp\"\n        },\n        \"original_document_ref\": {\n          \"path\": \"RelevantInvoiceNumber\"\n        },\n        \"reason_code\": {\n          \"default\": \"06\"\n        }\n      },\n      \"lines\": {\n        \"path\": \"ItemDetails\",\n        \"fields\": {\n          \"description\": {\n            \"path\": \"HSDesc\"\n          },\n          \"quantity\": {\n            \"path\": \"Quantity\"\n          },\n          \"unit_price\": {\n            \"path\": \"UnitPrice\"\n          },\n          \"source_tax_code\": {\n            \"path\": \"TaxRate\"\n          }\n        }\n      }\n    },\n    \"review\": {\n      \"confident\": [\n        {\n          \"field\": \"source_document_ref\",\n          \"proposed_path\": \"InvoiceNumber\"\n        }\n      ],\n      \"confirm\": [],\n      \"auto_suggested\": [\n        {\n          \"field\": \"reason_code\",\n          \"section\": \"document\",\n          \"default\": \"06\",\n          \"reason\": \"No payload field matched; defaulted to [06]. Confirm or replace it.\"\n        }\n      ],\n      \"missing_required\": [],\n      \"supply\": [\n        {\n          \"what\": \"tax_code_mappings\"\n        }\n      ]\n    }\n  }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('draft returned', ()=>pm.response.to.have.status(200));",
                  "const b=pm.response.json();",
                  "pm.test('nothing was activated', ()=>pm.expect(b.data.proposed_rules).to.be.an('object'));"
                ]
              }
            }
          ]
        },
        {
          "name": "Dry-run a mapping",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/mappings/test",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "mappings",
                "test"
              ]
            },
            "description": "Requires `mappings.view`. **Stores nothing.**\n\nThis is the tool that makes onboarding a configuration task: paste the merchant's\nJSON, see exactly what would be filed, iterate. Pass `rules` to try an unsaved\nmapping, `mapping_id` to try a specific version, or neither to use the active one.\n\n**One-click remediation.** When the transform hits a missing required field, the `422` carries a `suggested_rule_patch` - a minimal rules fragment (a `default` where the engine has a safe one, else the field with the path it was sought at) the UI can merge into the mapping and re-run.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"document_type\": \"sales_invoice\",\n  \"payload\": {\n    \"receipt\": {\n      \"no\": \"RST-8891\",\n      \"issued\": \"2026-07-21T10:04:00+03:00\"\n    },\n    \"customer\": {\n      \"name\": \"Walk-in\",\n      \"pin\": null\n    },\n    \"items\": [\n      {\n        \"sku\": \"BF-01\",\n        \"desc\": \"Beef Stew\",\n        \"qty\": 2,\n        \"price\": 650.0,\n        \"vat\": \"A\"\n      },\n      {\n        \"sku\": \"CH-02\",\n        \"desc\": \"Chapati\",\n        \"qty\": 3,\n        \"price\": 50.0,\n        \"vat\": \"A\"\n      }\n    ],\n    \"totals\": {\n      \"gross\": 1450.0\n    }\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Transformed",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Payload transformed. Nothing was stored.\",\n  \"data\": {\n    \"document\": {\n      \"source_document_ref\": \"RST-8891\",\n      \"currency\": \"KES\",\n      \"total_incl_tax\": 1450.0,\n      \"total_tax\": 200.0,\n      \"total_excl_tax\": 1250.0\n    },\n    \"lines\": [\n      {\n        \"line_no\": 1,\n        \"item_code\": \"1234567890\",\n        \"description\": \"Beef Stew\",\n        \"quantity\": 2.0,\n        \"unit_price\": 650.0,\n        \"tax_code\": \"VAT_16\",\n        \"tax_rate\": 16.0,\n        \"taxable_amount\": 1120.69,\n        \"tax_amount\": 179.31,\n        \"line_total\": 1300.0\n      }\n    ],\n    \"mapping_version\": 2\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Mapping error",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Required field [source_document_ref] was not found at path [receipt.no] in the payload.\",\n  \"errors\": {\n    \"error_code\": \"MISSING_FIELD\",\n    \"field\": \"source_document_ref\",\n    \"path\": \"receipt.no\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Missing field (with patch)",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Required field [source_document_ref] was not found at path [receipt.no] in the payload.\",\n  \"errors\": {\n    \"error_code\": \"MISSING_FIELD\",\n    \"field\": \"source_document_ref\",\n    \"path\": \"receipt.no\",\n    \"suggested_rule_patch\": {\n      \"document\": {\n        \"source_document_ref\": {\n          \"path\": \"receipt.no\"\n        }\n      }\n    }\n  }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Publish a mapping version",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/mappings",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "mappings"
              ]
            },
            "description": "Requires `mappings.manage`. Creates the next version number and, unless `activate` is false, makes it the one in force. Exactly one version per document type is ever active.\n\n**Save-time completeness.** Rules are validated against the document-type requirement matrix before insert. Every mandatory field must resolve to a payload `path` **or** a `default`; otherwise the save is refused **422 `MAPPING_INCOMPLETE`** with `missing_fields` and a `suggested_rule_patch` for one-click remediation. Matrix - `sales_invoice`: header `source_document_ref`,`issued_at`; lines `description`,`quantity`,`unit_price`,`source_tax_code`. `credit_note`/`debit_note` also need header `original_document_ref`,`reason_code` (the engine can default `reason_code` to `06`). `stock_movement`: header `source_document_ref`,`issued_at`; lines `source_item_code`,`quantity`. `item_registration`: header `source_document_ref`; lines `source_item_code`,`description`,`source_tax_code`. `customer_registration`: header `source_document_ref`,`customer_name` (no lines).\n\n**Derived line calculations (optional field-spec keys, for legacy payloads such as VSCU):**\n- `divide_by` - after reading a numeric field, divide it by another field in the SAME line (a path) or by a numeric constant. Use it when the payload carries a line TOTAL where a per-unit price is needed: `\"unit_price\": {\"path\": \"ItemAmount\", \"divide_by\": \"Quantity\", \"type\": \"decimal\"}` - this avoids double-multiplying quantity on `qty>1` lines. A zero or missing divisor leaves the value untouched (no divide-by-zero).\n- `from_header` - for a discount declared ONCE at the header rather than per line: `\"discount\": {\"from_header\": \"Invoice.Discount\"}`. The header amount is spread across the lines in proportion to each line's pre-discount gross, and the LAST line absorbs the rounding remainder, so the per-line discounts sum EXACTLY to the header figure.\nBoth are opt-in - a mapping without them is unchanged. See the **201 (legacy VSCU: divide_by + from_header)** example below.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"document_type\": \"sales_invoice\",\n  \"activate\": true,\n  \"notes\": \"Nested receipt format, tax-inclusive prices\",\n  \"rules\": {\n    \"document\": {\n      \"source_document_ref\": \"receipt.no\",\n      \"issued_at\": {\n        \"path\": \"receipt.issued\",\n        \"format\": \"iso8601\"\n      },\n      \"customer_name\": \"customer.name\",\n      \"total_incl_tax\": {\n        \"path\": \"totals.gross\",\n        \"type\": \"decimal\"\n      }\n    },\n    \"lines\": {\n      \"path\": \"items\",\n      \"fields\": {\n        \"source_item_code\": \"sku\",\n        \"description\": \"desc\",\n        \"quantity\": {\n          \"path\": \"qty\",\n          \"type\": \"decimal\",\n          \"default\": 1\n        },\n        \"unit_price\": {\n          \"path\": \"price\",\n          \"type\": \"decimal\"\n        },\n        \"discount\": {\n          \"path\": \"disc\",\n          \"type\": \"decimal\",\n          \"default\": 0\n        },\n        \"source_tax_code\": \"vat\"\n      }\n    },\n    \"options\": {\n      \"amounts_include_tax\": true,\n      \"total_tolerance\": 0.01\n    }\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 Created",
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Mapping version 3 created.\",\n  \"data\": {\n    \"id\": 33,\n    \"document_type\": \"sales_invoice\",\n    \"version\": 3,\n    \"is_active\": true\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Financial type needs lines",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"A mapping for [sales_invoice] must declare rules.lines.path \\u2014 a financial document is built from its lines.\",\n  \"errors\": {\n    \"rules.lines.path\": [\n      \"Required for financial document types.\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Mapping incomplete",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"This mapping is incomplete for [sales_invoice]. These required fields have no payload path and no default: document.issued_at, lines.fields.source_tax_code.\",\n  \"errors\": {\n    \"error_code\": \"MAPPING_INCOMPLETE\",\n    \"missing_fields\": [\n      \"document.issued_at\",\n      \"lines.fields.source_tax_code\"\n    ],\n    \"suggested_rule_patch\": {\n      \"document\": {\n        \"issued_at\": {\n          \"path\": \"<set the payload path>\"\n        }\n      },\n      \"lines\": {\n        \"fields\": {\n          \"source_tax_code\": {\n            \"path\": \"<set the payload path>\"\n          }\n        }\n      }\n    }\n  }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "201 (legacy VSCU: divide_by + from_header)",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Accept",
                    "value": "application/json"
                  },
                  {
                    "key": "Authorization",
                    "value": "Bearer {{token}}"
                  },
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{base_url}}/v1/integrations/{{integration_id}}/mappings",
                  "host": [
                    "{{base_url}}"
                  ],
                  "path": [
                    "v1",
                    "integrations",
                    "{{integration_id}}",
                    "mappings"
                  ]
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"document_type\": \"sales_invoice\",\n  \"activate\": true,\n  \"notes\": \"Legacy VSCU: header-level discount + pre-discount unit price\",\n  \"rules\": {\n    \"document\": {\n      \"source_document_ref\": \"Invoice.InvoiceNumber\",\n      \"issued_at\": {\n        \"path\": \"Invoice.InvoiceTimestamp\",\n        \"format\": \"iso8601\"\n      }\n    },\n    \"lines\": {\n      \"path\": \"Invoice.ItemDetails\",\n      \"fields\": {\n        \"source_item_code\": \"HSCode\",\n        \"description\": \"HSDesc\",\n        \"quantity\": {\n          \"path\": \"Quantity\",\n          \"type\": \"decimal\"\n        },\n        \"unit_price\": {\n          \"path\": \"ItemAmount\",\n          \"divide_by\": \"Quantity\",\n          \"type\": \"decimal\"\n        },\n        \"discount\": {\n          \"from_header\": \"Invoice.Discount\"\n        },\n        \"source_tax_code\": \"TaxRate\"\n      }\n    },\n    \"options\": {\n      \"amounts_include_tax\": false\n    }\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Mapping version 1 created.\",\n  \"data\": {\n    \"id\": 512,\n    \"pos_integration_id\": 10,\n    \"document_type\": \"sales_invoice\",\n    \"version\": 1,\n    \"is_active\": true,\n    \"rules\": {\n      \"document\": {\n        \"source_document_ref\": \"Invoice.InvoiceNumber\",\n        \"issued_at\": {\n          \"path\": \"Invoice.InvoiceTimestamp\",\n          \"format\": \"iso8601\"\n        }\n      },\n      \"lines\": {\n        \"path\": \"Invoice.ItemDetails\",\n        \"fields\": {\n          \"source_item_code\": \"HSCode\",\n          \"description\": \"HSDesc\",\n          \"quantity\": {\n            \"path\": \"Quantity\",\n            \"type\": \"decimal\"\n          },\n          \"unit_price\": {\n            \"path\": \"ItemAmount\",\n            \"divide_by\": \"Quantity\",\n            \"type\": \"decimal\"\n          },\n          \"discount\": {\n            \"from_header\": \"Invoice.Discount\"\n          },\n          \"source_tax_code\": \"TaxRate\"\n        }\n      },\n      \"options\": {\n        \"amounts_include_tax\": false\n      }\n    },\n    \"notes\": \"Legacy VSCU: header-level discount + pre-discount unit price\",\n    \"created_at\": \"2026-08-20T09:00:00+00:00\"\n  }\n}"
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Activate an older version (roll back)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/mappings/31/activate",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "mappings",
                "31",
                "activate"
              ]
            },
            "description": "Requires `mappings.manage`. Rolling back is a first-class operation because a bad mapping is discovered in production, not in review."
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Mapping version 1 is now active.\",\n  \"data\": {\n    \"id\": 31,\n    \"version\": 1,\n    \"is_active\": true\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "List code mappings",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/code-mappings?type=tax&per_page=50",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "code-mappings"
              ],
              "query": [
                {
                  "key": "type",
                  "value": "tax"
                },
                {
                  "key": "per_page",
                  "value": "50"
                }
              ]
            },
            "description": "Requires `mappings.view`. Types: `tax`, `item`, `unit`, `payment_method`."
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Code mappings retrieved.\",\n  \"data\": [\n    {\n      \"id\": 1,\n      \"type\": \"tax\",\n      \"pos_code\": \"A\",\n      \"target_code\": \"VAT_16\",\n      \"tax_rate\": \"16.00\",\n      \"description\": \"Standard rated\"\n    }\n  ],\n  \"pagination\": {\n    \"total\": 1,\n    \"per_page\": 50,\n    \"current_page\": 1,\n    \"last_page\": 1,\n    \"from\": 1,\n    \"to\": 1\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Upsert code mappings (bulk)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/code-mappings",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "code-mappings"
              ]
            },
            "description": "Requires `mappings.manage`. Upserts up to 1,000 at a time - onboarding sends\nhundreds.\n\n**A `tax` mapping must state its `tax_rate` explicitly.** An unmapped or\nun-rated tax code is refused at ingestion rather than defaulted: guessing a rate\nfiles the wrong amount of tax, and nobody notices until an audit.\n\nAn unmapped **item** code, by contrast, falls back to the source code - an\nunclassified item is a data-quality problem, not a wrong tax figure.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"mappings\": [\n    {\n      \"type\": \"tax\",\n      \"pos_code\": \"A\",\n      \"target_code\": \"VAT_16\",\n      \"tax_rate\": 16,\n      \"description\": \"Standard rated\"\n    },\n    {\n      \"type\": \"tax\",\n      \"pos_code\": \"E\",\n      \"target_code\": \"VAT_EXEMPT\",\n      \"tax_rate\": 0,\n      \"description\": \"Exempt\"\n    },\n    {\n      \"type\": \"tax\",\n      \"pos_code\": \"Z\",\n      \"target_code\": \"VAT_ZERO\",\n      \"tax_rate\": 0,\n      \"description\": \"Zero rated\"\n    },\n    {\n      \"type\": \"item\",\n      \"pos_code\": \"BF-01\",\n      \"target_code\": \"1234567890\",\n      \"description\": \"Beef Stew\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Saved",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"4 code mapping(s) saved.\",\n  \"data\": {\n    \"upserted\": 4\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Tax mapping without a rate",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Mapping #0 maps tax code [A] but gives no tax_rate. A tax mapping must state its rate explicitly.\",\n  \"errors\": {\n    \"mappings.0.tax_rate\": [\n      \"Required for mappings of type tax.\"\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Item catalogue",
      "description": "The merchant's products, described once in plain terms, then **registered** with whichever aggregator a till files through.\n\nWhy it matters: eTIMS refuses a sale line for an item it has never seen (`ETIMS_MAPPING_ERROR: no eTIMS item code`). Registering an item obtains the code KRA files under, and - the payoff - the Agent **auto-creates the item code mapping** for you, so sales then resolve cleanly.\n\nProvider-agnostic: for an aggregator that takes items inline (Digitax) the register call is a no-op (`not_required: true`). For one that needs registration (Advatech) the Agent builds the eTIMS item code, calls the aggregator, and records it. Registration is **idempotent** - a product already registered for a till is skipped.\n\nFlow: add products (account-level) → register them for a till → they file.",
      "item": [
        {
          "name": "List catalogue items",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/accounts/{{account_id}}/items",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "accounts",
                "{{account_id}}",
                "items"
              ]
            },
            "description": "Requires `mappings.view`. Each item shows its per-aggregator `registrations` (the code it was assigned, and status)."
          },
          "response": []
        },
        {
          "name": "Add a catalogue item",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/accounts/{{account_id}}/items",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "accounts",
                "{{account_id}}",
                "items"
              ]
            },
            "description": "Requires `mappings.manage`.\n\n- `source_item_code` - what your **till** sends for this product (becomes the item mapping's `pos_code`).\n- `item_class_code` - the KRA item classification (`itemClsCd`), chosen from the aggregator's code list.\n- `tax_code` - canonical tax (e.g. `VAT_16`).\n- `packaging_unit` / `quantity_unit` / `origin_country` / `item_type_code` - eTIMS attributes (sensible defaults `NT` / `U` / `KE` / `2`).\n\nOne item per `source_item_code` per account - a duplicate returns `409`.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"source_item_code\": \"MILK-01\",\n  \"name\": \"Strawberry Milkshake\",\n  \"item_class_code\": \"5020230100\",\n  \"tax_code\": \"VAT_16\",\n  \"item_type_code\": \"2\",\n  \"packaging_unit\": \"NT\",\n  \"quantity_unit\": \"U\",\n  \"origin_country\": \"KE\",\n  \"default_price\": 381.36\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 Created",
              "status": "Created",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Catalogue item created.\",\n  \"data\": {\n    \"id\": 7,\n    \"source_item_code\": \"MILK-01\",\n    \"name\": \"Strawberry Milkshake\",\n    \"item_class_code\": \"5020230100\",\n    \"tax_code\": \"VAT_16\",\n    \"default_price\": 381.36,\n    \"status\": \"active\",\n    \"registrations\": []\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ]
        },
        {
          "name": "Update a catalogue item",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/accounts/{{account_id}}/items/7",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "accounts",
                "{{account_id}}",
                "items",
                "7"
              ]
            },
            "description": "Requires `mappings.manage`. Send only the fields you want to change.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"default_price\": 400.00\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Register catalogue with a till's aggregator",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/items/register",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "items",
                "register"
              ]
            },
            "description": "Requires `mappings.manage`. Registers the account's active catalogue with this till's aggregator and **auto-creates the item code mappings** for the ones that succeed. Optional `item_ids` limits it to specific items; omit to register all.\n\n- Advatech: builds each eTIMS item code and calls `Save Items`.\n- Digitax (inline): `not_required: true`, nothing to do.\n- **Idempotent:** items already registered for this till are skipped.",
            "body": {
              "mode": "raw",
              "raw": "{}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Registered (Advatech)",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"1 registered, 0 failed, 0 already done.\",\n  \"data\": {\n    \"processed\": 1,\n    \"registered\": 1,\n    \"failed\": 0,\n    \"skipped\": 0,\n    \"not_required\": false,\n    \"results\": [\n      { \"source_item_code\": \"MILK-01\", \"item_cd\": \"KE2NTU0000001\", \"sequence\": 1, \"status\": \"registered\", \"error\": null }\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "200 Not required (inline aggregator)",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"This aggregator takes items inline; no registration was needed.\",\n  \"data\": { \"processed\": 0, \"registered\": 0, \"failed\": 0, \"skipped\": 3, \"not_required\": true, \"results\": [] }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ]
        },
        {
          "name": "Sync KRA code lists",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/reference/sync",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "reference",
                "sync"
              ]
            },
            "description": "Requires `mappings.manage`. Pulls this till's provider KRA code lists - quantity/packing units, payment types, item classifications, refund reasons, countries - into the cache. VSCU serves them (`selectCodes` + `selectItemsClass`); a provider that doesn't returns `supported: false`. Run it once during onboarding (and again if KRA updates its codes). The reply reports how many codes were synced."
          },
          "response": [
            {
              "name": "200 Synced",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"1580 reference codes synced from vscu.\",\n  \"data\": { \"supported\": true, \"synced\": 1580 }\n}",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ]
        },
        {
          "name": "Look up KRA codes",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/reference?type=quantity_unit&search=",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "reference"
              ],
              "query": [
                {
                  "key": "type",
                  "value": "quantity_unit",
                  "description": "quantity_unit | packaging_unit | payment_type | item_class | country | refund_reason | tax_type | item_type"
                },
                {
                  "key": "search",
                  "value": "",
                  "description": "Matches code or label (e.g. a product name to find its item_class)."
                }
              ]
            },
            "description": "Requires `mappings.view`. Browse / search the synced KRA codes for this till's provider, so onboarding can pick valid codes for a product. Filter by `type` and `search` (matches code or label), paginated.\n\n**Units** (`quantity_unit`, `packaging_unit`) are complete lists and are **validated** when you create a catalogue item - a bad one is refused. **`item_class`** is lookup-only: the KRA list is large/incomplete, so it is never used to reject a product, only to help you find the right classification."
          },
          "response": [
            {
              "name": "200 Codes",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Reference codes.\",\n  \"data\": [\n    { \"code_type\": \"quantity_unit\", \"code\": \"U\", \"label\": \"Each\", \"extra\": { \"class\": \"10\" } },\n    { \"code_type\": \"quantity_unit\", \"code\": \"KG\", \"label\": \"Kilogram\", \"extra\": { \"class\": \"10\" } }\n  ],\n  \"pagination\": { \"total\": 43, \"per_page\": 25, \"current_page\": 1, \"last_page\": 2, \"from\": 1, \"to\": 25 }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ]
        }
      ]
    },
    {
      "name": "7. Documents",
      "description": "\n**Lifecycle**\n\n```\nreceived → normalized ─┬→ queued → submitting ─┬→ accepted\n     │                 │                       ├→ rejected\n     │                 │                       └→ retry_scheduled → queued\n     │                 └→ on_hold ──(payment)──→ queued\n     └→ normalization_failed\n                                  retries exhausted → dead_letter\n```\n\n`on_hold` is **not** a failure: it consumes no retry budget and can never reach\ndead-letter. Transitions happen through named endpoints; there is no generic\n\"update status\".\n\n**Receipt PDF**\n\n`GET /documents/{id}/download` renders any document - at any point in that\nlifecycle - as a branded PDF. Only an `accepted` document with a control code\ngets the control-unit block and QR; everything else is stamped NOT FISCALISED.\nIt needs `documents.download`, a permission separate from `documents.view`,\nbecause the file carries the customer's PIN and full line detail, and every\ncall lands in the audit log as `document.downloaded`.",
      "item": [
        {
          "name": "List documents",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/documents?status=on_hold,retry_scheduled&from=2026-07-01&per_page=25",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "documents"
              ],
              "query": [
                {
                  "key": "status",
                  "value": "on_hold,retry_scheduled"
                },
                {
                  "key": "from",
                  "value": "2026-07-01"
                },
                {
                  "key": "per_page",
                  "value": "25"
                }
              ]
            },
            "description": "Requires `documents.view`. Filters: `pos_integration_id`, `status` (comma-separated), `document_type`, `reference`, `from`, `to`, `needs_attention=1`. Always scoped to the caller's account."
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Documents retrieved.\",\n  \"data\": [\n    {\n      \"id\": 90001,\n      \"pos_integration_id\": 10,\n      \"document_type\": \"sales_invoice\",\n      \"reference\": \"RST-8891\",\n      \"status\": \"accepted\",\n      \"issued_at\": \"2026-07-21T10:04:00+00:00\",\n      \"currency\": \"KES\",\n      \"total_excl_tax\": 1250.0,\n      \"total_tax\": 200.0,\n      \"total_incl_tax\": 1450.0,\n      \"kra_control_code\": \"KRACU00918273645\",\n      \"attempt_count\": 1,\n      \"last_error_code\": null\n    }\n  ],\n  \"pagination\": {\n    \"total\": 1,\n    \"per_page\": 25,\n    \"current_page\": 1,\n    \"last_page\": 1,\n    \"from\": 1,\n    \"to\": 1\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Get document with lines and attempts",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/documents/{{document_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "documents",
                "{{document_id}}"
              ]
            },
            "description": "Requires `documents.view`. Includes every submission attempt - the evidence trail when a merchant disputes whether an invoice was filed."
          },
          "response": [
            {
              "name": "200 Success",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Success\",\n  \"data\": {\n    \"id\": 90001,\n    \"reference\": \"RST-8891\",\n    \"status\": \"accepted\",\n    \"kra_control_code\": \"KRACU00918273645\",\n    \"total_incl_tax\": 1450.0,\n    \"totals_agree_with_lines\": true,\n    \"mapping_version\": 2,\n    \"lines\": [\n      {\n        \"line_no\": 1,\n        \"item_code\": \"1234567890\",\n        \"source_item_code\": \"BF-01\",\n        \"description\": \"Beef Stew\",\n        \"quantity\": 2.0,\n        \"unit_price\": 650.0,\n        \"tax_code\": \"VAT_16\",\n        \"source_tax_code\": \"A\",\n        \"tax_rate\": 16.0,\n        \"taxable_amount\": 1120.69,\n        \"tax_amount\": 179.31,\n        \"line_total\": 1300.0\n      }\n    ],\n    \"attempts\": [\n      {\n        \"attempt_no\": 1,\n        \"provider\": \"thirdparty\",\n        \"outcome\": \"transient_failure\",\n        \"http_status\": null,\n        \"error_code\": \"TIMEOUT\",\n        \"duration_ms\": 30000,\n        \"attempted_at\": \"2026-07-21T10:04:31+00:00\"\n      },\n      {\n        \"attempt_no\": 2,\n        \"provider\": \"thirdparty\",\n        \"outcome\": \"accepted\",\n        \"http_status\": 200,\n        \"error_code\": null,\n        \"duration_ms\": 812,\n        \"attempted_at\": \"2026-07-21T10:09:33+00:00\"\n      }\n    ]\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Download document PDF",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Accept",
                "value": "application/pdf"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/documents/{{document_id}}/download",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "documents",
                "{{document_id}}",
                "download"
              ]
            },
            "description": "Requires `documents.download` - deliberately separate from `documents.view`, because the PDF carries the customer's name, PIN and full line detail.\n\nReturns `application/pdf` as an attachment (`sales-invoice-RST-8891-20260814.pdf`). Every call is written to the audit log as `document.downloaded`.\n\nA document that has not been fiscalised still downloads, stamped **NOT FISCALISED** with its status and last error, and carries no QR - a draft must never read as a valid tax receipt.\n\nBranding (name, logo, website, colours, footer copy) comes from the `app_details` reference-data category, so a rebrand is an API edit rather than a deploy."
          },
          "response": [
            {
              "name": "200 PDF",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "text",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/pdf"
                },
                {
                  "key": "Content-Disposition",
                  "value": "attachment; filename=\"sales-invoice-RST-8891-20260814.pdf\""
                },
                {
                  "key": "Cache-Control",
                  "value": "max-age=0, no-store, private"
                }
              ],
              "body": "%PDF-1.7\n… binary PDF stream …\n%%EOF\n\nPostman shows this as bytes - use Send and Download to save the file.\n\nThe page carries: masthead and brand mark, buyer/supplier blocks, the line\nitems with the eTIMS band letter actually filed (A-E, not the canonical\ntax code), the per-band tax summary, the control-unit block (SCU Id, SCU\nInvoice No, MRC No, Signature, Internal Data) and a QR of the KRA\nverification link."
            },
            {
              "name": "200 PDF (not fiscalised)",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "text",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/pdf"
                },
                {
                  "key": "Content-Disposition",
                  "value": "attachment; filename=\"sales-invoice-RST-8891-20260814.pdf\""
                },
                {
                  "key": "Cache-Control",
                  "value": "max-age=0, no-store, private"
                }
              ],
              "body": "%PDF-1.7\n… binary PDF stream …\n%%EOF\n\nA document that was never accepted still downloads, but the page is stamped\nNOT FISCALISED with its status and last error, and carries NO QR and no\ncontrol-unit block - it must never be mistaken for a valid tax receipt."
            },
            {
              "name": "403 Missing permission",
              "status": "Forbidden",
              "code": 403,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"You do not have permission to perform this action.\",\n  \"errors\": {\n    \"required_permissions\": [\n      \"documents.download\"\n    ]\n  }\n}"
            },
            {
              "name": "404 Another tenant's document",
              "status": "Not Found",
              "code": 404,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Resource not found.\"\n}"
            }
          ]
        },
        {
          "name": "Retry a document",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/documents/{{document_id}}/retry",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "documents",
                "{{document_id}}",
                "retry"
              ]
            },
            "description": "Requires `documents.retry`.\n\nApplies to `rejected`, `dead_letter` and `retry_scheduled` documents.\n`reset_attempts` clears the consumed retry budget - the append-only attempt log\nis untouched, so history is preserved.\n\n**An `accepted` document can never be retried.** It already carries a control\ncode; re-filing it would create a duplicate tax record. That refusal is\nunconditional and no permission overrides it.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"reset_attempts\": true\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Queued",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Document queued for another submission attempt.\",\n  \"data\": {\n    \"id\": 90001,\n    \"status\": \"queued\",\n    \"attempt_count\": 0\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Already accepted",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"This document has already been accepted (control code KRACU00918273645). Re-submitting it would create a duplicate filing.\"\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Reprocess a normalisation failure",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/documents/{{document_id}}/reprocess",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "documents",
                "{{document_id}}",
                "reprocess"
              ]
            },
            "description": "Requires `documents.retry`.\n\n**Reprocess is not retry.** Retry re-SUBMITS a document that already normalised. Reprocess re-runs the NORMALISATION stage - the thing that failed - from the document's retained raw payload, through the CURRENT active mapping and code mappings. Use it after fixing a mapping or adding a missing code (e.g. an unmapped tax code) to recover a `normalization_failed` document without asking the till to send anything again.\n\nOnly a `normalization_failed` document can be reprocessed. A document that reached the queue, a hold, or a filing is refused with 422 - re-transforming a filed document would risk a duplicate filing, and that line is exactly what keeps idempotency intact. A `normalization_failed` document was never filed, so re-attempting it can never duplicate a filing.\n\nOn success the document is re-normalised in place (same row, same reference - never a second document) and queued for submission; if the subscription is unpaid it is held instead."
          },
          "response": [
            {
              "name": "200 Re-normalised and queued",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Document re-normalised and queued for submission.\",\n  \"data\": {\n    \"id\": 90007,\n    \"reference\": \"REC-57\",\n    \"status\": \"queued\",\n    \"total_tax\": 13.79,\n    \"last_error_code\": null\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Still cannot normalise",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"The document still cannot be normalised: No code mapping exists for tax code [Z].\",\n  \"errors\": {\n    \"error_code\": \"UNMAPPED_TAX_CODE\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Not reprocessable (already past normalisation)",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"Only a document that failed normalisation can be reprocessed; this one is [accepted]. Re-transforming a document that reached the queue or a filing would risk a duplicate filing.\"\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Reprocess all failures for an integration",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/integrations/{{integration_id}}/documents/reprocess-failed",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "integrations",
                "{{integration_id}}",
                "documents",
                "reprocess-failed"
              ]
            },
            "description": "Requires `documents.retry`.\n\nRe-drives EVERY `normalization_failed` document on the integration through the current active mapping and code mappings - fix the mapping once, then clear the whole backlog in a single call. Recovered documents are queued for submission (or held if the subscription is unpaid); documents that still cannot normalise are left as `normalization_failed` with their error refreshed.\n\nOptional `limit` bounds how many are processed in one call.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"limit\": 500\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Backlog cleared",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"12 of 12 recovered; 0 still failing.\",\n  \"data\": {\n    \"processed\": 12,\n    \"recovered\": 12,\n    \"still_failed\": 0\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b.data).to.have.property('recovered');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Submit now (synchronously)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/documents/{{document_id}}/submit",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "documents",
                "{{document_id}}",
                "submit"
              ]
            },
            "description": "Requires `documents.retry`. Used by support to verify a fix without waiting for the worker. Returns 409 if another worker already claimed the document - the claim is what makes double submission impossible."
          },
          "response": [
            {
              "name": "200 Attempted",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Submission attempted.\",\n  \"data\": {\n    \"id\": 90001,\n    \"status\": \"accepted\",\n    \"attempt\": {\n      \"attempt_no\": 3,\n      \"outcome\": \"accepted\",\n      \"error_code\": null\n    }\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "409 Claimed elsewhere",
              "status": "Conflict",
              "code": 409,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"The document was claimed by another worker; nothing was submitted twice.\"\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Hold a document",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/documents/{{document_id}}/hold",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "documents",
                "{{document_id}}",
                "hold"
              ]
            },
            "description": "Requires `documents.hold`. A manual hold is a decision about the **data** and survives a payment - releasing the billing backlog does not lift it."
          },
          "response": [
            {
              "name": "200 Held",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Document held. It will not be submitted until released.\",\n  \"data\": {\n    \"id\": 90001,\n    \"status\": \"on_hold\",\n    \"hold_reason\": \"manual\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Release a held document",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/documents/{{document_id}}/release",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "documents",
                "{{document_id}}",
                "release"
              ]
            },
            "description": "Requires `documents.release`. **Cannot bypass the billing gate** - if the subscription is unpaid this returns 402, otherwise 'release' would be a back door around non-payment."
          },
          "response": [
            {
              "name": "200 Released",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Document released for submission.\",\n  \"data\": {\n    \"id\": 90001,\n    \"status\": \"queued\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "402 Subscription unpaid",
              "status": "Payment Required",
              "code": 402,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"This POS integration is not currently permitted to submit (subscription not paid up or integration paused). Settle the subscription or resume the integration; held documents are then released automatically.\"\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Cancel a document",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Accept",
                "value": "application/json"
              },
              {
                "key": "Authorization",
                "value": "Bearer {{token}}"
              },
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/v1/documents/{{document_id}}/cancel",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "v1",
                "documents",
                "{{document_id}}",
                "cancel"
              ]
            },
            "description": "Requires `documents.cancel`. Only for documents never filed. A filed document must be corrected with a credit note instead - the record with the revenue authority cannot be withdrawn from here.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"reason\": \"Duplicate created by a till misconfiguration\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Cancelled",
              "status": "OK",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": true,\n  \"message\": \"Document cancelled. It will never be submitted.\",\n  \"data\": {\n    \"id\": 90001,\n    \"status\": \"cancelled\"\n  }\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            },
            {
              "name": "422 Already filed",
              "status": "Unprocessable Content",
              "code": 422,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"success\": false,\n  \"message\": \"This document has already been filed with the revenue authority and cannot be cancelled here. Issue a credit note instead.\"\n}",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "body": null,
                "url": {
                  "raw": ""
                }
              }
            }
          ],
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('envelope shape', function () {",
                  "    const b = pm.response.json();",
                  "    pm.expect(b).to.have.property('success');",
                  "    pm.expect(b).to.have.property('message');",
                  "});"
                ]
              }
            }
          ]
        }
      ]
    }
  ],
  "variable": [
    {
      "key": "base_url",
      "value": "https://kra-agent-sbx.symatechlabs.com/api",
      "type": "string"
    },
    {
      "key": "account_id",
      "value": "1"
    },
    {
      "key": "token",
      "value": "",
      "type": "string"
    },
    {
      "key": "pos_key_id",
      "value": "",
      "type": "string"
    },
    {
      "key": "pos_secret",
      "value": "",
      "type": "string"
    },
    {
      "key": "pos_sender_id",
      "value": "3c0e19f2f890ea7ca15e",
      "type": "string"
    },
    {
      "key": "sender_credential_id",
      "value": "1",
      "type": "string"
    },
    {
      "key": "integration_id",
      "value": "1",
      "type": "string"
    },
    {
      "key": "document_id",
      "value": "1",
      "type": "string"
    },
    {
      "key": "reference_data_id",
      "value": "1",
      "type": "string"
    },
    {
      "key": "mpesa_callback_secret",
      "value": "change-me",
      "type": "string"
    },
    {
      "key": "unsubscribe_token",
      "value": "",
      "type": "string"
    }
  ]
}