# Kosoor Merchant API > The Kosoor Merchant API lets your system work with the data of a business that uses Kosoor: its customers, sales invoices, payments and credit notes. Base URL `https://api.kosoor.com.sa`; every path starts with `/v1`; JSON with camelCase names, over HTTPS. Support: WhatsApp only, [+966 12 606 1770](https://wa.me/966126061770). No calls, no email. Quote the `Request-Id` of the call you are asking about. ## Instructions for AI agents - Authenticate every request with `Authorization: Bearer `. The merchant issues the key; there is no other login. - Build and test with a test key (`ksr_test_...`). It reaches only the merchant's sandbox. Check that every reply carries the header `Kosoor-Mode: test` while you build, and switch to a live key (`ksr_live_...`) only when the merchant is ready to go live. - Send an `Idempotency-Key` header on every `POST` and `PATCH`. Generate one key per logical operation (a UUID works) and send the same key again when you retry that operation. - Branch on the error's `code`, never on `message`. `message` is for people and changes with `Accept-Language`. - Take the ids of branches, items and payment methods from the catalogue endpoints: `GET /v1/branches`, `GET /v1/items` and `GET /v1/payment-methods`. Never guess or hard-code them. - Send only documented fields and query parameters: anything else is refused with `400 unknown_parameter`. - Read amounts from the reply, not from your request: a discount below an item's minimum price is reduced, and prices that include VAT are converted to the halala. - Read `zatca.status` on every sales invoice and credit note. `pending` is not a failure. - When something fails that the docs do not explain, tell the user to contact Kosoor support on WhatsApp (+966 12 606 1770, https://wa.me/966126061770) with the `Request-Id`. # Guides ## Start here > What Kosoor is, who gives you access, the words the API uses, and how Saudi e-invoicing (ZATCA) shows up in it. Read this page first if you have never worked with Kosoor or with Saudi e-invoicing. ### What Kosoor is Kosoor is a point-of-sale and business system for businesses in Saudi Arabia, many of them car washes and service centres. A business sells through Kosoor at the POS, at a self-service kiosk, on its online store and on the Kosoor dashboard. Kosoor keeps its customers, invoices, payments and accounts, and sends its invoices to ZATCA, the Saudi tax authority. The Merchant API lets another system read and write that data: customers, sales invoices, payments and credit notes. ### Merchant and partner - **Merchant**: a business that uses Kosoor. Its data lives in its own Kosoor account. - **Partner**: you. Your company builds a system, such as an ERP, a booking app or a fleet portal, that works with a merchant's data through this API. A key belongs to one merchant. To work with several merchants, you hold one key per merchant. ### How access works 1. The merchant issues an API key in the Kosoor dashboard, under **Settings → Developers**, and gives it to you. [Connect a merchant](https://docs.kosoor.com.sa/onboarding.md) walks through it. 2. You send the key with every request. The key alone tells Kosoor which merchant the call is for: you send no merchant id, and you need no Kosoor account or login of your own. 3. The key carries only the permissions the merchant granted, and may be limited to some of the merchant's branches. The merchant can revoke it at any time. There are two kinds of key. A test key (`ksr_test_...`) reaches only the merchant's sandbox, a separate copy of its catalogue; build with it. A live key (`ksr_live_...`) reaches the merchant's real data. See [Test mode](https://docs.kosoor.com.sa/test-mode.md). ### Words the API uses | Term | Meaning | |---|---| | Branch | One of the merchant's locations, such as a shop or a service centre. Every invoice and payment belongs to a branch, and a key may be limited to some branches. | | Item | Something the merchant sells: a product, a service or a subscription. An invoice line names an item by its id. | | POS | The point of sale in a branch, where a cashier takes the money. A sale made there shows `createdVia: "pos"` in the API. | | Payment method | How a customer pays, as the merchant set it up, such as cash or card. | | Treasury | Where money sits in the merchant's books, such as a cash box or a bank account. Each payment method pays into a treasury, which can differ by branch. | | Cost centre | A tag the merchant's accountant uses to group income and costs, such as a department or a project. An invoice can carry one in `costCentreId`. | | Car size | Many merchants price a service by the size of the car. An item priced per car size has no single catalogue price, so an invoice line for it must send `unitPrice`. | | Subscription | A package of visits a customer buys, such as a number of car washes. The API does not sell subscriptions: a line naming one answers `422 item_not_sellable`. | | Customer | A person or a company. Every invoice made through the API names one. | | SAR | The Saudi riyal. Every amount in the API is in SAR (`currency: "SAR"`). | | Halala | One hundredth of a riyal. Kosoor keeps amounts to the halala, so at most two decimals. | | VAT | Value added tax. The standard rate in Saudi Arabia is 15%. Each item carries its own rate in Kosoor, and each invoice line shows it in `vatRate`, for example `15`. | | VAT number | A business's VAT registration number: 15 digits that start and end with 3. | | Commercial registration (CR) | A company's registration number in Saudi Arabia. A company customer with no VAT number can be identified by it instead (`idType: "CRN"`). | ### ZATCA in five minutes ZATCA is the Zakat, Tax and Customs Authority. Its e-invoicing system, Fatoora, receives every invoice and credit note a Saudi business issues. Kosoor does that part for the merchant: you never call ZATCA, sign XML or build a QR code. You only read the result. #### Standard and simplified ZATCA has two kinds of invoice, and in Kosoor the customer decides which one an invoice is: | | Standard (tax invoice) | Simplified | |---|---|---| | Customer | a company | a person | | ZATCA's process | **clearance**: ZATCA checks the invoice and clears it before the buyer gets it | **reporting**: the invoice is issued first, then reported to ZATCA | | In the API | Kosoor clears it before it replies to you | Kosoor reports it after it replies to you | `zatca.kind` in the reply says which one it is: `standard` or `simplified`. A credit note is the same kind as the invoice it credits. #### What each status means | `zatca.status` | Meaning | |---|---| | `pending` | ZATCA has not accepted the document yet. A simplified document is `pending` until Kosoor reports it. A standard one is `pending` only when ZATCA did not answer in time; Kosoor sends it again later. | | `cleared` | ZATCA cleared this standard document. `zatca.qr` holds the QR code ZATCA returned. | | `reported` | ZATCA accepted the report of this simplified document. | | `rejected` | ZATCA refused the document. Its messages are in `zatca.errors`. | `pending` is not a failure. The document is issued, and its number and amounts do not change. A standard document that ZATCA rejects while Kosoor clears it is not saved at all: the call answers `422 zatca_rejected`. So a saved document turns `rejected` only after it was `pending`. What to do with each status is in [ZATCA results](https://docs.kosoor.com.sa/zatca.md). In the sandbox, documents are signed with ZATCA's test certificates and never reach the merchant's real ZATCA records. ### Where to go next 1. [Getting started](https://docs.kosoor.com.sa/index.md): the base URL and the rules every request follows. 2. [Connect a merchant](https://docs.kosoor.com.sa/onboarding.md): from the merchant's dashboard to your first call. 3. [Record a sale](https://docs.kosoor.com.sa/record-a-sale.md): the whole flow, with every request and reply. Source: https://docs.kosoor.com.sa/start-here --- ## Getting started > The base URL, how to get a key, your first request, and the rules every request follows. The Kosoor Merchant API lets your system work with a Kosoor merchant's data: its customers, sales invoices, payments and credit notes. New to Kosoor or to Saudi e-invoicing? Read [Start here](https://docs.kosoor.com.sa/start-here.md) first. ### Base URL ```text https://api.kosoor.com.sa ``` Every path starts with `/v1`. Requests and responses are JSON with camelCase names, over HTTPS. ### Get a key The merchant creates the key in the Kosoor dashboard, under **Settings → Developers → API keys**, and gives it to you. [Connect a merchant](https://docs.kosoor.com.sa/onboarding.md) shows each step. A key belongs to one merchant, carries only the permissions the merchant granted, and may be limited to some of its branches. See [Authentication](https://docs.kosoor.com.sa/authentication.md). ### Your first request ```bash curl https://api.kosoor.com.sa/v1/customers?limit=1 \ -H "Authorization: Bearer ksr_live_..." ``` ```json { "data": [], "hasMore": false } ``` ### Every object names its kind Every object the API returns carries `object`, a fixed string naming what it is: `customer`, `salesInvoice`, `payment` or `creditNote`. One kind always has the same value. Read it wherever one part of your code receives more than one kind, such as stored replies or logs, instead of guessing from the URL or from which fields are present. ```json { "id": "3f1c2a6e-8b4d-4e2a-9c1f-5d7e8a9b0c12", "object": "salesInvoice", "number": "INV-1042" } ``` ### On every request - **`Request-Id`**: every response carries one, success or failure. Quote it when you contact [Kosoor support](#support) about a call. - **Language**: send `Accept-Language: ar` for Arabic messages; anything else answers in English. Only messages change, never codes or field names. - **Strict input**: a body field or query parameter the endpoint does not know is refused with `unknown_parameter`, so a typo never passes silently. - **Body**: `Content-Type: application/json`, at most 1 MB. - **Writes**: every `POST`, `PATCH` and `DELETE` carries an `Idempotency-Key`, so a retry never creates a second record. See [Retries and idempotency](https://docs.kosoor.com.sa/idempotency.md). - **Rate limits**: a live key makes up to 1,200 reads and 300 writes a minute, a test key 300 and 60. Past that, `429 rate_limited` with `Retry-After`. See [Rate limits](https://docs.kosoor.com.sa/errors.md#rate_limit_error). - **Errors**: one body shape for every failure, with a stable `code`. See [Error codes](https://docs.kosoor.com.sa/errors.md). ### Support Kosoor support answers on WhatsApp only: [+966 12 606 1770](https://wa.me/966126061770). There is no phone line and no email. Quote the `Request-Id` of the call you are asking about. Source: https://docs.kosoor.com.sa/ --- ## Connect a merchant > Every step from the merchant signing in to the Kosoor dashboard to your first call with a test key, then going live. The merchant does the first steps in the Kosoor dashboard and you do the rest. If the merchant has never issued a key, send them this page. ### 1. The merchant signs in The merchant signs in to the Kosoor dashboard with a user that has the permission `integration.api_keys.management`. Without it, **Developers** does not appear under **Settings**. ### 2. Open Settings → Developers The **Developers** page has five tabs: | Tab | What it holds | |---|---| | **Overview** | the merchant's keys, the sandbox, and the calls of the last 30 days | | **API keys** | every key: issue, edit, revoke | | **Request log** | every call a key made in the last 30 days | | **Webhooks** | the endpoints keys registered, and every event sent to them; see [Webhooks](https://docs.kosoor.com.sa/webhooks.md) | | **Sandbox** | the separate copy of the catalogue that test keys reach | ![The Developers page on its Overview tab](https://docs.kosoor.com.sa/img/onboarding/developers-overview.en.png.md) ### 3. Create the sandbox On the **Sandbox** tab, the merchant clicks **Create sandbox**. Kosoor copies the merchant's branches, items, payment methods and chart of accounts into it; customers and invoices are not copied. Preparing it takes a few minutes, and test keys answer `503 sandbox_unavailable` until the tab shows **Sandbox ready**. The sandbox keeps the live ids of what it copies, so a branch, item or payment method id you use in the sandbox is the same id on live data. Customers are not copied, so their ids differ. ![The Sandbox tab showing Sandbox ready](https://docs.kosoor.com.sa/img/onboarding/sandbox-ready.en.png.md) ### 4. Issue a test key On the **API keys** tab, the merchant clicks **Issue a key** and fills in the form: | Field | What to choose | |---|---| | **Key mode** | **Test**. A test key reaches the sandbox only. It can be chosen once the sandbox exists. | | **Key name** | Your system's name. The name is how the merchant tells which system uses the key on the day it has to be revoked. | | **Permissions** | For each resource: **None**, **Read**, or **Read and write**. Grant the least that is enough; the table below says what each task needs. | | **Branches** | The branches the key may use. Leave all unticked and the key works in every branch. | | **Expires on** | Optional. The key stops at the start of this day. Kosoor sends a reminder email 14 days and then 3 days before. | ![The Issue a key form with Test selected](https://docs.kosoor.com.sa/img/onboarding/issue-test-key.en.png.md) The key is shown once, with the note **Copy the key now: it is not shown again**. The merchant copies it and sends it to you. Once that message is closed the key cannot be shown again: if it is lost, the merchant revokes it and issues a new one. A merchant can hold at most 50 active keys of each mode. ![The new key, shown once, with its Copy button](https://docs.kosoor.com.sa/img/onboarding/key-shown-once.en.png.md) #### Permissions each task needs | To | Grant | |---|---| | read the catalogue: branches, items, payment methods | `branches:read`, `items:read`, `payment_methods:read` | | find and create customers | `customers:write` | | issue sales invoices | `sales_invoices:write` | | record payments after the sale | `payments:write` | | issue credit notes | `credit_notes:write` | | only read, for example to mirror data into an ERP | the `read` level of each resource you read | `write` includes `read`. A call without the permission it needs answers `403 missing_permission`, and `requiredPermission` in the error names it. See [Authentication](https://docs.kosoor.com.sa/authentication.md). ### 5. Make your first call Read one of the customers every sandbox holds, with the test key: ```bash curl -i https://api.kosoor.com.sa/v1/customers/5a4db0c5-0000-4000-8000-000000000001 \ -H "Authorization: Bearer ksr_test_..." ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 Kosoor-Mode: test Request-Id: req_01JC3W8Z9Q4M2T7XKD5R6VBN0P ``` ```json { "id": "5a4db0c5-0000-4000-8000-000000000001", "object": "customer", "type": "person", "referenceId": null, "firstName": "Sandbox Person", "lastName": null, "phone": "+966500000001", "email": null, "isActive": true, "branchId": null, "company": null, "address": { "street": null, "buildingNumber": null, "postalCode": null, "district": null, "city": null, "country": null }, "metadata": {}, "standardInvoiceMissing": null, "createdAt": "2026-11-07T09:12:40.513Z" } ``` `Kosoor-Mode: test` says the call reached the sandbox. A live key's replies do not carry it. To make the same calls from Postman, import the [Postman collection](https://docs.kosoor.com.sa/postman/kosoor-merchant-api.postman_collection.json) and the [Kosoor test](https://docs.kosoor.com.sa/postman/kosoor-test.postman_environment.json) environment, and set `apiKey` to the test key. The collection holds every endpoint and is generated from the [OpenAPI document](https://docs.kosoor.com.sa/openapi.json). When you go live, switch to the [Kosoor live](https://docs.kosoor.com.sa/postman/kosoor-live.postman_environment.json) environment with the live key. ### 6. Find the call in the Request log On the **Request log** tab, the merchant sees every call a key made in the last 30 days, newest first. It filters by key mode (**Live** or **Test**), key, status, path and date. **Details** shows the call's `Request-Id`. Request and response bodies are kept only for refused and failed calls. ![The Request log tab with the first call and its details open](https://docs.kosoor.com.sa/img/onboarding/request-log.en.png.md) ### 7. Build in the sandbox Build the whole flow with the test key: [Record a sale](https://docs.kosoor.com.sa/record-a-sale.md), [Refund or correct a sale](https://docs.kosoor.com.sa/refunds.md), and whatever else your system does. The sandbox holds fixture customers with known outcomes, so you can test ZATCA rejections and other refusals without arranging the data. See [Test mode](https://docs.kosoor.com.sa/test-mode.md). ### 8. Go live When your system works in the sandbox: 1. The merchant issues a second key with **Key mode** set to **Live**, the same permissions and the same branches. 2. You replace the test key with the live key. Nothing else changes: same host, same paths, and the same ids for branches, items and payment methods. 3. You make one real call and the merchant checks it in the **Request log**, under **Live**. Go through the [go-live checklist](https://docs.kosoor.com.sa/go-live.md) before the first real sale. To stop a key, the merchant revokes it on the **API keys** tab. Every system using it stops at once, with `401 revoked_api_key`. Source: https://docs.kosoor.com.sa/onboarding --- ## Build with an AI agent > The Markdown copies, llms.txt and llms-full.txt this site serves for AI tools, the buttons on every page, and the agent skill you can install. Every page of this site is also served as plain Markdown, so an AI coding agent or a chat assistant can read the docs without guessing. ### Files for AI tools | File | What it holds | |---|---| | [`/llms.txt`](https://docs.kosoor.com.sa/llms.txt) | an index of every page, with instructions for AI agents at the top | | [`/llms-full.txt`](https://docs.kosoor.com.sa/llms-full.txt) | every guide and every endpoint contract (paths, parameters, request and reply fields) in one file | | any page with `.md` added | that page as Markdown, for example [`/record-a-sale.md`](https://docs.kosoor.com.sa/record-a-sale.md); the home page is `/index.md` | | [`/skill/kosoor-merchant-api/SKILL.md`](https://docs.kosoor.com.sa/skill/kosoor-merchant-api/SKILL.md) | an agent skill: the rules of this API in one short file | The Arabic site has its own copies under `/ar`, such as `/ar/llms.txt`. ### On every page Above every page are four buttons: - **Copy page as Markdown** copies the page, ready to paste into a chat. - **View as Markdown** opens the page's `.md` copy. - **Open in Claude** and **Open in ChatGPT** start a chat that reads the page. ### Install the agent skill The skill gives an AI coding agent the rules that matter most when it writes code against this API: send an `Idempotency-Key`, branch on `code`, build with a test key, take ids from the catalogue, and where to read the rest. For Claude Code, save it as a project skill, so everyone working on the repository gets it: ```bash mkdir -p .claude/skills/kosoor-merchant-api curl -fsSL https://docs.kosoor.com.sa/skill/kosoor-merchant-api/SKILL.md \ -o .claude/skills/kosoor-merchant-api/SKILL.md ``` Or as a personal skill, for every project on your machine: ```bash mkdir -p ~/.claude/skills/kosoor-merchant-api curl -fsSL https://docs.kosoor.com.sa/skill/kosoor-merchant-api/SKILL.md \ -o ~/.claude/skills/kosoor-merchant-api/SKILL.md ``` Claude Code loads the skill when a task involves the Kosoor API. For another agent, add the file's contents to the instructions that agent reads, or point it at `https://docs.kosoor.com.sa/llms.txt`. ### Give the agent a test key Let the agent build and run against the sandbox with a test key (`ksr_test_...`), never a live one. Put the key in an environment variable or a local file your repository ignores, not in the prompt or the code. See [Test mode](https://docs.kosoor.com.sa/test-mode.md). Source: https://docs.kosoor.com.sa/ai-agents --- ## Record a sale > The whole flow of a sale, with every request and reply - read the catalogue, find or create the customer, issue the invoice, and take the payment at issue or later. A sale through the API takes four steps. Each step below shows the full request and the full reply, made with a test key against the sandbox. 1. Read the catalogue: the branch, the items and the payment method. 2. Find the customer, or create them. 3. Issue the invoice, with the payment or without it. 4. Take the payment later, when the invoice was not paid at issue. The key needs `branches:read`, `items:read`, `payment_methods:read`, `customers:write`, `sales_invoices:write` and, for step 4, `payments:write`. The ids in these examples follow the [test mode fixtures](https://docs.kosoor.com.sa/test-mode.md#fixtures) where one exists. The invoice and receipt `number` values are made up: each merchant sets its own numbering. ### 1. Read the catalogue An invoice names its branch, each line's item and the payment method by id. Take those ids from the catalogue, never from a guess: | Endpoint | Permission | Gives you | |---|---|---| | `GET /v1/branches` | `branches:read` | the branches your key may use: `branchId` | | `GET /v1/items` | `items:read` | the items the merchant sells: `lines[].itemId` | | `GET /v1/payment-methods` | `payment_methods:read` | the merchant's payment methods: `payment.paymentMethodId` | The sandbox keeps the live ids of the branches, items and payment methods it copied, so ids you map in the sandbox hold on live data. ### 2. Find or create the customer Every invoice names a customer: the API makes no walk-in sale. Look the customer up by phone, or by the `referenceId` you gave them: ```bash curl "https://api.kosoor.com.sa/v1/customers?phone=%2B966500000001" \ -H "Authorization: Bearer ksr_test_..." ``` When `data` is empty, create the customer with `POST /v1/customers`. If two of your requests race and the phone was taken in between, the create answers `409 phone_taken` and `existingId` names the customer to use. [Customers](https://docs.kosoor.com.sa/customers.md) has both requests and every rule. The examples below use two sandbox fixtures: **Sandbox Person** (`...0001`), a person, and **Sandbox Standard Co** (`...0002`), a company. ### 3. Issue the invoice `POST /v1/sales-invoices` creates and issues the invoice in one call; there is no draft. Every `POST` carries an `Idempotency-Key`: if the reply is lost, send the same request with the same key, and you get the same invoice back instead of a second one. See [Retries and idempotency](https://docs.kosoor.com.sa/idempotency.md). #### A person who pays in full The customer is a person, so the invoice is simplified. `payment` without `amount` pays the whole invoice. Here the price includes VAT, so `pricesIncludeVat` is `true`. ```bash curl https://api.kosoor.com.sa/v1/sales-invoices \ -H "Authorization: Bearer ksr_test_..." \ -H "Idempotency-Key: 4e9f2a7c-1b3d-4c5e-8f6a-9b0c1d2e3f4a" \ -H "Content-Type: application/json" \ -d '{ "customerId": "5a4db0c5-0000-4000-8000-000000000001", "branchId": "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", "pricesIncludeVat": true, "lines": [ { "itemId": "7c8d9e0f-1a2b-4c3d-8e4f-5a6b7c8d9e0f", "quantity": 1, "unitPrice": 115 } ], "payment": { "paymentMethodId": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9" }, "referenceId": "order-20417" }' ``` `201 Created`: ```json { "id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", "object": "salesInvoice", "number": "INV-1042", "issueDate": "2026-11-07", "supplyDate": null, "dueDate": null, "branchId": "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", "customerId": "5a4db0c5-0000-4000-8000-000000000001", "currency": "SAR", "subtotal": 100, "discountTotal": 0, "vatTotal": 15, "total": 115, "amountPaid": 115, "amountDue": 0, "lines": [ { "itemId": "7c8d9e0f-1a2b-4c3d-8e4f-5a6b7c8d9e0f", "name": "Full wash", "description": null, "quantity": 1, "unitPrice": 100, "unitDiscount": 0, "vatRate": 15, "vatAmount": 15, "lineTotal": 115 } ], "notes": null, "costCentreId": null, "referenceId": "order-20417", "metadata": {}, "createdVia": "api", "zatca": { "kind": "simplified", "status": "pending", "uuid": null, "counter": null, "hash": null, "qr": null, "submittedAt": null, "clearedAt": null, "reportedAt": null, "errors": [] } } ``` Read these from the reply: - **Line prices are before VAT.** The 115 you sent including VAT is shown as `unitPrice: 100` with `vatAmount: 15`. - **`total` is what the customer pays.** Compare it with what your system expected: a discount below an item's minimum price is reduced, and a price that includes VAT can move the total by a halala. See [Sales invoices](https://docs.kosoor.com.sa/sales-invoices.md). - **`zatca.status` is `pending`.** A simplified invoice is reported to ZATCA after the reply; it turns `reported`. The document fields stay `null` until Kosoor signs it. See [ZATCA results](https://docs.kosoor.com.sa/zatca.md). The money stays on the invoice (`amountPaid`): a sale to a person paid at issue creates no separate payment. #### A company on account The customer is a company, so the invoice is standard, and Kosoor has ZATCA clear it before replying. Without `payment`, the whole invoice goes on the company's account. Here prices are sent before VAT. ```bash curl https://api.kosoor.com.sa/v1/sales-invoices \ -H "Authorization: Bearer ksr_test_..." \ -H "Idempotency-Key: 0c5b7d3e-9f2a-4b1c-8d6e-3a4f5b6c7d8e" \ -H "Content-Type: application/json" \ -d '{ "customerId": "5a4db0c5-0000-4000-8000-000000000002", "branchId": "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", "pricesIncludeVat": false, "lines": [ { "itemId": "8d9e0f1a-2b3c-4d4e-9f5a-6b7c8d9e0f1a", "quantity": 2, "unitPrice": 100, "description": "Plates ABC 1234 and XYZ 5678" } ], "dueDate": "2026-12-07", "notes": "Fleet contract 2026-14", "referenceId": "order-20418", "metadata": { "fleetContract": "2026-14" } }' ``` `201 Created`: ```json { "id": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f", "object": "salesInvoice", "number": "INV-1043", "issueDate": "2026-11-07", "supplyDate": null, "dueDate": "2026-12-07", "branchId": "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", "customerId": "5a4db0c5-0000-4000-8000-000000000002", "currency": "SAR", "subtotal": 200, "discountTotal": 0, "vatTotal": 30, "total": 230, "amountPaid": 0, "amountDue": 230, "lines": [ { "itemId": "8d9e0f1a-2b3c-4d4e-9f5a-6b7c8d9e0f1a", "name": "Fleet wash", "description": "Plates ABC 1234 and XYZ 5678", "quantity": 2, "unitPrice": 100, "unitDiscount": 0, "vatRate": 15, "vatAmount": 30, "lineTotal": 230 } ], "notes": "Fleet contract 2026-14", "costCentreId": null, "referenceId": "order-20418", "metadata": { "fleetContract": "2026-14" }, "createdVia": "api", "zatca": { "kind": "standard", "status": "cleared", "uuid": "e7f8a9b0-c1d2-4e3f-a4b5-c6d7e8f9a0b1", "counter": 18, "hash": "zXab+32ksuccBzrDiE27f65AmOvbZ2ixbnSSRnoRKzA=", "qr": "ARBTYW5kYm94IE1lcmNoYW50Ag8zOTk5OTk5OTk5MDAwMDMDFDIwMjYtMTEtMDdUMDk6NDE6MjJaBAYyMzAuMDAFBTMwLjAw", "submittedAt": "2026-11-07T09:41:21.884Z", "clearedAt": "2026-11-07T09:41:22.517Z", "reportedAt": null, "errors": [] } } ``` `zatca.status` is `cleared` and `zatca.qr` holds the QR code ZATCA returned. If ZATCA does not answer in time, the reply is still `201`, with `status: "pending"`; Kosoor clears it later. If ZATCA rejects it, nothing is saved and the call answers `422 zatca_rejected`. Both are covered in [ZATCA results](https://docs.kosoor.com.sa/zatca.md). Only a company can buy on account. For a person, an invoice without `payment`, or with a `payment.amount` below the total, answers `422 on_account_not_allowed`. #### Taking part of the money at issue `payment.amount` below the total pays that much at issue, and the rest stays on the company's account: ```json { "paymentMethodId": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9", "amount": 100 } ``` An amount above the total answers `400 invalid_parameter`. To pay in full, leave `amount` out rather than sending your own total. ### 4. Take the payment later When the company pays, record the money with `POST /v1/payments` and allocate it to the invoice: ```bash curl https://api.kosoor.com.sa/v1/payments \ -H "Authorization: Bearer ksr_test_..." \ -H "Idempotency-Key: 7a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d" \ -H "Content-Type: application/json" \ -d '{ "customerId": "5a4db0c5-0000-4000-8000-000000000002", "branchId": "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", "paymentMethodId": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9", "amount": 230, "date": "2026-11-09", "allocations": [ { "invoiceId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f", "amount": 230 } ], "referenceId": "bank-tx-55190" }' ``` `201 Created`: ```json { "id": "d3e4f5a6-b7c8-4d9e-8f0a-2b3c4d5e6f7a", "object": "payment", "number": "RCT-0031", "date": "2026-11-09", "customerId": "5a4db0c5-0000-4000-8000-000000000002", "branchId": "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", "paymentMethodId": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9", "amount": 230, "discount": 0, "allocations": [ { "invoiceId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f", "amount": 230 } ], "unallocatedAmount": 0, "notes": null, "referenceId": "bank-tx-55190", "metadata": {}, "createdVia": "api" } ``` After it, `GET /v1/sales-invoices/c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f` shows `amountPaid: 230` and `amountDue: 0`. Money you do not allocate stays as the customer's credit, shown in `unallocatedAmount`. [Payments](https://docs.kosoor.com.sa/payments.md) has every rule. ### 5. Give the customer the invoice `GET /v1/sales-invoices/{id}/pdf` returns the invoice on A4. A simplified invoice's PDF is ready at once. A standard invoice's PDF answers `409 invoice_not_cleared` until ZATCA clears it. ### What can go wrong | Error | What it means | What to do | |---|---|---| | `422 customer_address_incomplete` | a standard invoice needs the company's full address and VAT number or ID; `missing` lists the gaps | fill them with `PATCH /v1/customers/{id}`, then send the invoice again | | `422 on_account_not_allowed` | a person was not paid in full | send `payment` without `amount` | | `422 credit_limit_exceeded` | the company's account would pass its credit limit | take payment at issue, or ask the merchant | | `422 customer_inactive` | the merchant disabled the customer | ask the merchant | | `422 item_not_sellable` | the item is a subscription, or not for sale | use another item | | `400 missing_parameter` on `lines[i].unitPrice` | the item is priced per car size | send `unitPrice` | | `403 branch_not_allowed` | the key is limited to other branches | use a branch from `GET /v1/branches` | | `422 branch_not_onboarded` | the branch is not registered with ZATCA, so it cannot issue a standard invoice | ask the merchant | | `422 zatca_rejected` | ZATCA rejected a standard invoice; nothing was saved | read `zatcaErrors`, fix the data, send it again under a new `Idempotency-Key` | Every code is in [Error codes](https://docs.kosoor.com.sa/errors.md). Source: https://docs.kosoor.com.sa/record-a-sale --- ## Refund or correct a sale > Credit all or part of an issued sales invoice, and either pay the money back or take it off what the customer owes, with full requests and replies. An issued sales invoice never changes and is never deleted. To give money back, take it off what the customer owes, or correct a mistake, you issue a credit note against it with `POST /v1/credit-notes`. The key needs `credit_notes:write`. A credit note credits items of one invoice. It takes its branch, customer and prices from the invoice, and the credited goods go back to stock. The money goes one of two ways: | You send | The credit note's total | |---|---| | `refund` with a `paymentMethodId` | is paid back to the customer through that payment method | | no `refund` | comes off what the customer owes on the invoice; anything above that stays as the customer's credit | The credit note `number` values below are made up: each merchant sets its own numbering. `reason` tells ZATCA why: `cancellation` (the sale is cancelled, fully or in part), `refund` (goods or services are returned and the money is given back) or `informationChange` (the invoice's details were wrong). ### Refund a person The person from [Record a sale](https://docs.kosoor.com.sa/record-a-sale.md) paid 115 SAR for a wash that did not happen. Credit the line and pay the money back: ```bash curl https://api.kosoor.com.sa/v1/credit-notes \ -H "Authorization: Bearer ksr_test_..." \ -H "Idempotency-Key: 5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f" \ -H "Content-Type: application/json" \ -d '{ "invoiceId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", "reason": "refund", "lines": [ { "itemId": "7c8d9e0f-1a2b-4c3d-8e4f-5a6b7c8d9e0f", "quantity": 1 } ], "refund": { "paymentMethodId": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9" }, "notes": "Booking cancelled by the customer", "referenceId": "refund-20417" }' ``` `201 Created`: ```json { "id": "e4f5a6b7-c8d9-4e0f-9a1b-3c4d5e6f7a8b", "object": "creditNote", "number": "CN-0007", "invoiceId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", "customerId": "5a4db0c5-0000-4000-8000-000000000001", "branchId": "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", "issueDate": "2026-11-08", "reason": "refund", "currency": "SAR", "lines": [ { "itemId": "7c8d9e0f-1a2b-4c3d-8e4f-5a6b7c8d9e0f", "name": "Full wash", "description": null, "quantity": 1, "unitPrice": 100, "unitDiscount": 0, "vatRate": 15, "vatAmount": 15, "lineTotal": 115 } ], "subtotal": 100, "discountTotal": 0, "vatTotal": 15, "total": 115, "refund": { "paymentMethodId": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9", "amount": 115 }, "notes": "Booking cancelled by the customer", "referenceId": "refund-20417", "metadata": {}, "createdVia": "api", "zatca": { "kind": "simplified", "status": "pending", "uuid": null, "counter": null, "hash": null, "qr": null, "submittedAt": null, "clearedAt": null, "reportedAt": null, "errors": [] } } ``` The invoice was simplified, so the credit note is too: it is reported to ZATCA after the reply and turns `reported`. `refund.amount` is the money paid back. A person carries no balance. Without `refund`, a person can be credited only up to what the invoice still owes; above that the call answers `400 invalid_parameter` on `refund`. A person who paid in full is therefore always refunded with `refund`. ### Credit a company's account The company from [Record a sale](https://docs.kosoor.com.sa/record-a-sale.md) has not paid its 230 SAR invoice yet, and one of the two washes is cancelled. Credit one unit without `refund`, so it comes off what the company owes: ```bash curl https://api.kosoor.com.sa/v1/credit-notes \ -H "Authorization: Bearer ksr_test_..." \ -H "Idempotency-Key: 1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b" \ -H "Content-Type: application/json" \ -d '{ "invoiceId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f", "reason": "cancellation", "lines": [ { "itemId": "8d9e0f1a-2b3c-4d4e-9f5a-6b7c8d9e0f1a", "quantity": 1 } ], "referenceId": "cancel-20418-1" }' ``` `201 Created`: ```json { "id": "f5a6b7c8-d9e0-4f1a-8b2c-4d5e6f7a8b9c", "object": "creditNote", "number": "CN-0008", "invoiceId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f", "customerId": "5a4db0c5-0000-4000-8000-000000000002", "branchId": "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", "issueDate": "2026-11-08", "reason": "cancellation", "currency": "SAR", "lines": [ { "itemId": "8d9e0f1a-2b3c-4d4e-9f5a-6b7c8d9e0f1a", "name": "Fleet wash", "description": "Plates ABC 1234 and XYZ 5678", "quantity": 1, "unitPrice": 100, "unitDiscount": 0, "vatRate": 15, "vatAmount": 15, "lineTotal": 115 } ], "subtotal": 100, "discountTotal": 0, "vatTotal": 15, "total": 115, "refund": null, "notes": null, "referenceId": "cancel-20418-1", "metadata": {}, "createdVia": "api", "zatca": { "kind": "standard", "status": "cleared", "uuid": "a9b0c1d2-e3f4-4a5b-8c6d-7e8f9a0b1c2d", "counter": 19, "hash": "ZHXx9tKJ0Zr2b/RlF8NG4LYnWT15u5Rq0/+tYkccjjk=", "qr": "ARBTYW5kYm94IE1lcmNoYW50Ag8zOTk5OTk5OTk5MDAwMDMDFDIwMjYtMTEtMDhUMDg6MTU6MDRaBAYxMTUuMDAFBTE1LjAw", "submittedAt": "2026-11-08T08:15:03.902Z", "clearedAt": "2026-11-08T08:15:04.611Z", "reportedAt": null, "errors": [] } } ``` `refund` is `null`: nothing was paid back. The invoice's `amountDue` drops from 230 to 115. Had the company already paid in full, the 115 would stay as its credit. The invoice was standard, so ZATCA cleared the credit note before the reply. An invoice ZATCA has not cleared yet cannot be credited: the call answers `409 invoice_not_cleared`. Retry once `GET /v1/sales-invoices/{id}` shows `zatca.status: "cleared"`. ### Correct a mistake An invoice with wrong details, such as the wrong customer, is corrected in two calls: 1. Credit every line of it with `reason: "informationChange"`. If the customer paid, send `refund` to pay the money back; a company may instead keep it as credit. 2. Issue a new invoice with the right details, as in [Record a sale](https://docs.kosoor.com.sa/record-a-sale.md). ### How much can be credited The credits on an invoice never add up to more than its total, and never more of an item than was sold. Refunds made at the POS or on the dashboard count too. Above that, the call answers `422 credit_exceeds_invoice`. A refund can also not pass what the invoice has collected, less what was already paid back (`422 refund_exceeds_collected`). ### When the API refuses | Error | What to do | |---|---| | `409 invoice_not_cleared` | the standard invoice is still `pending`; retry once it is `cleared` | | `422 invoice_not_creditable` | ZATCA rejected the invoice, or it was imported; the merchant corrects it on the dashboard | | `422 loyalty_refund_blocked` | retry once a few minutes later; if refused again, the merchant issues the credit note from the dashboard | | `422 used_subscription_not_refundable` | the sale was a subscription with visits used; the merchant handles it on the dashboard | | `422 refund_method_not_supported` | the sale was paid by card on the merchant's online store; the API cannot refund the card | | `409 invoice_busy` | another request held the invoice for more than 30 seconds; send it again | [Credit notes](https://docs.kosoor.com.sa/credit-notes.md) has every rule and every error. Source: https://docs.kosoor.com.sa/refunds --- ## Keep an ERP in sync > Copy a merchant's invoices, payments and credit notes into your system without gaps or duplicates, and write to Kosoor safely with your own reference ids. This page is for a system that mirrors a merchant's documents, such as an ERP or an accounting package, and may also write to Kosoor. ### Read what is new Every list returns the newest records first, whichever channel created them: the POS, the kiosk, the online store, the dashboard or the API. A record created while you page appears at the top of the list, so it never shifts the pages you are reading. See [Pagination](https://docs.kosoor.com.sa/pagination.md). So to fetch what is new since your last run: 1. Keep the `id` of the newest record you stored for each list. 2. Ask for the first page: `GET /v1/sales-invoices?limit=100`. 3. Store each record until you meet the `id` you kept. Stop there. 4. If you did not meet it and `hasMore` is `true`, ask for the next page with `startingAfter` set to the last `id` of this page, and go on from step 3. 5. Keep the `id` of the first record of the first page for the next run. ```bash curl "https://api.kosoor.com.sa/v1/sales-invoices?limit=100&startingAfter=c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f" \ -H "Authorization: Bearer ksr_live_..." ``` ```json { "data": [ { "id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", "object": "salesInvoice", "number": "INV-1042", "issueDate": "2026-11-07", "supplyDate": null, "dueDate": null, "branchId": "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", "customerId": "5a4db0c5-0000-4000-8000-000000000001", "currency": "SAR", "subtotal": 100, "discountTotal": 0, "vatTotal": 15, "total": 115, "amountPaid": 115, "amountDue": 0, "notes": null, "costCentreId": null, "referenceId": "order-20417", "metadata": {}, "createdVia": "api", "zatca": { "kind": "simplified", "status": "reported", "uuid": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e", "counter": 17, "hash": "dZBGdHmJoIQ0SOi+mt6KcGC6mWlVmpjbPEJ4y/3tps4=", "qr": "ARBTYW5kYm94IE1lcmNoYW50Ag8zOTk5OTk5OTk5MDAwMDMDFDIwMjYtMTEtMDdUMDk6MzA6MTJaBAYxMTUuMDAFBTE1LjAw", "submittedAt": "2026-11-07T09:30:40.118Z", "clearedAt": null, "reportedAt": "2026-11-07T09:30:41.204Z", "errors": [] } } ], "hasMore": false } ``` A list leaves out each invoice's `lines`. Add `expand=lines` to get them, and `expand=customer` to embed the customer (which also needs `customers:read`); repeat the parameter for both: `expand=lines&expand=customer`. The same steps work for `GET /v1/payments` and `GET /v1/credit-notes`. The credit note list includes refunds made at the POS and on the dashboard. To load history once, filter by date instead: `issuedAfter` and `issuedBefore` on sales invoices and credit notes (instants in ISO 8601, such as `2026-01-01T00:00:00Z`), and `dateFrom` and `dateTo` on payments (days, such as `2026-01-01`). ### Tell the channels apart `createdVia` says which channel created a record: `api`, `dashboard`, `pos` (the point of sale in a branch), `kiosk`, `storefront` (the merchant's online store), or `unknown` for a record saved before Kosoor recorded the channel. `api` covers every key the merchant issued, not only yours. To recognise your own records, give them your own `referenceId`, or a key in `metadata`. ### Records that change after you stored them A list shows each record once, when it is created. Some fields change later: | Field | Changes when | |---|---| | `zatca.status` and the other `zatca` fields | ZATCA answers: `pending` turns `cleared`, `reported` or `rejected` | | `amountPaid`, `amountDue` of an invoice | a payment is allocated to it, or a credit note takes an amount off it | | any field of a customer | the merchant or a key edits the customer | Read a record again with `GET` on its `id` when you need it fresh. `zatcaStatus=pending` on `GET /v1/sales-invoices` and `GET /v1/credit-notes` lists the documents still waiting for ZATCA. ZATCA results that arrive later are pushed to you by webhook; see [Webhooks](https://docs.kosoor.com.sa/webhooks.md). ### Write without duplicates Two things protect a write, and they work together: - **`Idempotency-Key`**: sending the same request again with the same key returns the first reply and creates nothing. Kosoor keeps a key for 30 days. See [Retries and idempotency](https://docs.kosoor.com.sa/idempotency.md). - **`referenceId`**: your own id for the record, such as your ERP's document number. No two records of the same kind may share one: | Resource | `referenceId` is unique across | |---|---| | customers | the merchant's customers; it is set once and never changes | | sales invoices | the merchant's sales invoices and credit notes together | | payments | the merchant's payments | | credit notes | the merchant's sales invoices and credit notes together | So if your system crashes after sending an invoice and loses its `Idempotency-Key`, sending the invoice again under a new key cannot create a second one: it answers `409 reference_id_taken`. `existingId` names the record that holds the reference when it is the same kind of record and your key may read it, and `GET /v1/sales-invoices?referenceId=...` finds it in any case: ```json { "error": { "type": "invalid_request_error", "code": "reference_id_taken", "message": "Another resource already has this referenceId.", "param": "referenceId", "docUrl": "https://docs.kosoor.com.sa/errors#reference_id_taken", "existingId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f" } } ``` Before you write, you can also look the reference up: every list takes a `referenceId` filter. ### Map the catalogue once Your ERP's branches, items and payment methods map to Kosoor ids from `GET /v1/branches`, `GET /v1/items` and `GET /v1/payment-methods`. The sandbox keeps the live ids of the catalogue it copied, so a mapping you build in the sandbox holds on live data. Customers are the exception: the sandbox has its own, so map them again on live data, by `referenceId`, phone or VAT number. Source: https://docs.kosoor.com.sa/erp-sync --- ## ZATCA results > What each ZATCA status on an invoice or credit note means, what your system should do with it, and how results that arrive later reach you. Every sales invoice and credit note carries a `zatca` object that says where it stands with ZATCA. If ZATCA is new to you, read [Start here](https://docs.kosoor.com.sa/start-here.md#zatca-in-five-minutes) first. ```json { "kind": "standard", "status": "cleared", "uuid": "e7f8a9b0-c1d2-4e3f-a4b5-c6d7e8f9a0b1", "counter": 18, "hash": "zXab+32ksuccBzrDiE27f65AmOvbZ2ixbnSSRnoRKzA=", "qr": "ARBTYW5kYm94IE1lcmNoYW50Ag8zOTk5OTk5OTk5MDAwMDMDFDIwMjYtMTEtMDdUMDk6NDE6MjJaBAYyMzAuMDAFBTMwLjAw", "submittedAt": "2026-11-07T09:41:21.884Z", "clearedAt": "2026-11-07T09:41:22.517Z", "reportedAt": null, "errors": [] } ``` | Field | Meaning | |---|---| | `kind` | `standard` for a company customer, `simplified` for a person | | `status` | `pending`, `cleared`, `reported` or `rejected` | | `uuid` | the document's UUID in ZATCA's records | | `counter` | ZATCA's invoice counter value | | `hash` | the document's hash, which the next document chains to | | `qr` | the QR code in Base64; on a standard document, the one ZATCA returned when it cleared it | | `submittedAt` | when Kosoor signed the document, in UTC | | `clearedAt` | when ZATCA cleared a standard document, in UTC | | `reportedAt` | when ZATCA accepted the report of a simplified document, in UTC | | `errors` | ZATCA's messages when it rejected the document, each with `code`, `category` and `message` | `uuid`, `counter`, `hash`, `qr` and `submittedAt` are `null` until Kosoor signs the document. ### What to do with each status #### `pending` The document is issued and valid in Kosoor; ZATCA has not accepted it yet. - **Simplified**: this is the normal state right after issue. Kosoor reports the document after its reply, and it turns `reported`. Its PDF is ready at once; its XML answers `409 invoice_not_cleared` until ZATCA accepts the report. - **Standard**: ZATCA did not answer while Kosoor was clearing it. Kosoor sends it again later, and it turns `cleared` or `rejected`. Until then its PDF and XML answer `409 invoice_not_cleared`, and a credit note against it answers `409 invoice_not_cleared` too. Do not issue the sale again: a new invoice is a second sale. If your request timed out and you do not know whether it went through, send it again with the same `Idempotency-Key`, which returns the first reply. #### `cleared` ZATCA cleared this standard document. Its PDF and XML are ready, and `qr` holds ZATCA's QR code. Nothing to do. #### `reported` ZATCA accepted the report of this simplified document. Its XML is ready. Nothing to do. #### `rejected` ZATCA refused the document. `errors` holds its messages: ```json { "kind": "standard", "status": "rejected", "uuid": "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", "counter": 20, "hash": "PO9Je+Enxi/tc010iSgP6H+piJDaQh6zQZ41v6aDPsk=", "qr": "ARBTYW5kYm94IE1lcmNoYW50Ag8zOTk5OTk5OTk5MDAwMDMDFDIwMjYtMTEtMDdUMTA6MDI6MTFaBAYxMTUuMDAFBTE1LjAw", "submittedAt": "2026-11-07T10:02:11.430Z", "clearedAt": null, "reportedAt": null, "errors": [ { "code": "BR-KSA-63", "category": "KSA", "message": "Buyer street is missing" } ] } ``` The document stays in Kosoor, but: - its XML answers `409 invoice_not_cleared` for good, and so does a standard document's PDF; - an invoice ZATCA rejected cannot be credited through the API (`422 invoice_not_creditable`). Show the merchant ZATCA's messages. The merchant corrects the sale on the Kosoor dashboard. ### When ZATCA rejects a standard invoice at issue A standard invoice is cleared before Kosoor replies. If ZATCA rejects it then, nothing is saved, no number is used, and the call answers `422 zatca_rejected` with ZATCA's messages in `zatcaErrors`: ```json { "error": { "type": "business_rule_error", "code": "zatca_rejected", "message": "ZATCA rejected the invoice, so nothing was saved. ZATCA's messages are in zatcaErrors.", "param": null, "docUrl": "https://docs.kosoor.com.sa/errors#zatca_rejected", "zatcaErrors": [ { "code": "BR-KSA-63", "category": "KSA", "message": "Sandbox sample rejection: the buyer's street is missing" } ] } } ``` Fix the data ZATCA names, usually on the customer with `PATCH /v1/customers/{id}`, and send the invoice again under a new `Idempotency-Key`. The same holds for a credit note on a standard invoice. In the sandbox, the fixture customers **Sandbox ZATCA Pending Co** and **Sandbox ZATCA Rejected Co** give these two outcomes every time. See [Test mode](https://docs.kosoor.com.sa/test-mode.md). ### Results that arrive later A document that answered `pending` gets its result later, and Kosoor pushes it to your system: register a webhook endpoint for the `salesInvoice.zatca*` and `creditNote.zatca*` events. See [Webhooks](https://docs.kosoor.com.sa/webhooks.md). Without one, read the document again with `GET /v1/sales-invoices/{id}` or `GET /v1/credit-notes/{id}` when your system needs its status, and use `zatcaStatus=pending` on either list to find the documents still waiting. Source: https://docs.kosoor.com.sa/zatca --- ## Go-live checklist > What to check in your integration before the merchant's first real sale goes through it. Go through this list with the test key before you switch to the live key. ### Keys - [ ] The key is kept on your server only, never in a mobile app, a browser or a code repository. - [ ] The live key has only the permissions your system uses, and only the branches it works in. - [ ] Your system reads the key from configuration, so replacing it needs no new release. - [ ] You know who at the merchant revokes and reissues the key, and you handle `401 revoked_api_key` and `401 expired_api_key` by alerting someone, not by retrying. - [ ] If the key has an expiry date, someone owns renewing it before that day. ### Requests - [ ] Every `POST`, `PATCH` and `DELETE` sends an `Idempotency-Key`, one per logical operation, and a retry sends the same key. - [ ] You retry only timeouts, dropped connections, `409 idempotency_in_progress` and `5xx`, with the same key. See [Retries and idempotency](https://docs.kosoor.com.sa/idempotency.md). - [ ] Your code branches on `code`, never on `message`. - [ ] You log the `Request-Id` of every call, so you can quote it to support. - [ ] Branch, item and payment method ids come from `GET /v1/branches`, `GET /v1/items` and `GET /v1/payment-methods`, not from constants. - [ ] Each sales invoice, payment and credit note carries your own `referenceId`, and your system handles `409 reference_id_taken` by reading `existingId`. - [ ] A phone in a query string is encoded: `%2B966...`, not `+966...`. ### Money - [ ] You read `total`, `lines[].unitDiscount`, `amountPaid` and `amountDue` from the reply, not from your request. - [ ] To pay an invoice in full, you send `payment` without `amount`. - [ ] Sales to a person are paid at issue; only company customers buy on account. ### ZATCA - [ ] Your system shows or stores `zatca.status` for every invoice and credit note, and treats `pending` as issued, not failed. - [ ] A `rejected` document reaches a person at the merchant who can correct it. - [ ] You handle `422 zatca_rejected` and `422 customer_address_incomplete` on standard invoices; you tested both with the sandbox fixtures. - [ ] You fetch a standard invoice's PDF only once it is `cleared`. ### Switching to live - [ ] The merchant issued a live key with the same permissions and branches as the test key. - [ ] Your system checks the `Kosoor-Mode` header: present as `test` in the sandbox, absent on live data. - [ ] You made one real call and the merchant found it in **Settings → Developers → Request log**, under **Live**. - [ ] Customers you created in the sandbox are created again, or found, on live data: the sandbox's customers are its own. ### Support Kosoor support answers on WhatsApp only: [+966 12 606 1770](https://wa.me/966126061770). There is no phone line and no email. Send the `Request-Id` of the call and what you expected. Source: https://docs.kosoor.com.sa/go-live --- ## Customers > Create a person or a company, what each type requires, the ID types, finding a customer by phone or by your own reference, and how updates work. Every sales invoice and payment made through the API names a customer. A customer is a person or a company, and the type decides the invoice: a company gets a standard invoice that ZATCA clears, a person gets a simplified one. See [Start here](https://docs.kosoor.com.sa/start-here.md#standard-and-simplified). | Endpoint | Permission | |---|---| | `POST /v1/customers` | `customers:write` | | `GET /v1/customers` | `customers:read` | | `GET /v1/customers/{id}` | `customers:read` | | `PATCH /v1/customers/{id}` | `customers:write` | ### Create a person A person needs `type` and `phone`. The phone is in E.164 form: `+`, the country code, then the number, such as `+966501234567`. ```bash curl https://api.kosoor.com.sa/v1/customers \ -H "Authorization: Bearer ksr_test_..." \ -H "Idempotency-Key: 6f1d2c3b-4a5e-4f60-9b7a-8c9d0e1f2a3b" \ -H "Content-Type: application/json" \ -d '{ "type": "person", "firstName": "Sara", "lastName": "Al-Harbi", "phone": "+966501234567", "email": "sara@example.com", "referenceId": "crm-88412" }' ``` `201 Created`: ```json { "id": "0f8fad5b-d9cb-469f-a165-70867728950e", "object": "customer", "type": "person", "referenceId": "crm-88412", "firstName": "Sara", "lastName": "Al-Harbi", "phone": "+966501234567", "email": "sara@example.com", "isActive": true, "branchId": null, "company": null, "address": { "street": null, "buildingNumber": null, "postalCode": null, "district": null, "city": null, "country": null }, "metadata": {}, "standardInvoiceMissing": null, "createdAt": "2026-11-07T09:30:12.512Z" } ``` A person may also send `address.street`, `address.district`, `address.city` and `address.country`. `buildingNumber`, `postalCode` and the `company` object are for companies only, and so is a missing phone. ### Create a company A company needs `type`, `company.name` and `address.country`. The country is an ISO 3166 two-letter code, such as `SA`. A company does not need a phone, and it never takes `firstName` or `lastName`: its name is `company.name`. ```bash curl https://api.kosoor.com.sa/v1/customers \ -H "Authorization: Bearer ksr_test_..." \ -H "Idempotency-Key: 2b8e4f9a-1c3d-4e5f-8a6b-7c8d9e0f1a2b" \ -H "Content-Type: application/json" \ -d '{ "type": "company", "phone": "+966112345678", "company": { "name": "Al Noor Logistics", "vatNumber": "310123456700003" }, "address": { "street": "Prince Sultan Road", "buildingNumber": "7421", "postalCode": "23521", "district": "Al Rawdah", "city": "Jeddah", "country": "SA" }, "referenceId": "crm-90017" }' ``` `201 Created`: ```json { "id": "3f1c2a6e-8b4d-4e2a-9c1f-5d7e8a9b0c12", "object": "customer", "type": "company", "referenceId": "crm-90017", "firstName": null, "lastName": null, "phone": "+966112345678", "email": null, "isActive": true, "branchId": null, "company": { "name": "Al Noor Logistics", "vatNumber": "310123456700003", "idType": null, "idValue": null, "creditLimit": 0, "creditPeriodDays": null }, "address": { "street": "Prince Sultan Road", "buildingNumber": "7421", "postalCode": "23521", "district": "Al Rawdah", "city": "Jeddah", "country": "SA" }, "metadata": {}, "standardInvoiceMissing": [], "createdAt": "2026-11-07T09:31:05.208Z" } ``` #### What a standard invoice needs A company can be created with only its name and country, but a standard invoice needs more. `standardInvoiceMissing` lists what this company still lacks, as field paths, and is empty when nothing is missing: | Needed | When | |---|---| | `company.vatNumber`, or `company.idType` with `company.idValue` | always | | `address.street`, `address.city`, `address.country` | always | | `address.buildingNumber`, `address.postalCode`, `address.district` | when the country is `SA` | An invoice for a company that still lacks any of them answers `422 customer_address_incomplete`, with the same list in `missing`. Check `standardInvoiceMissing` before you invoice, and fill the gaps with `PATCH`. #### Company rules | Field | Rule | |---|---| | `company.vatNumber` | 15 digits that start and end with 3 (`400 invalid_vat_number`); no other customer may have it (`409 vat_number_taken`) | | `company.idType`, `company.idValue` | sent together, both set or both `null`; `idValue` is 1 to 50 letters and digits | | `address.buildingNumber` | 4 digits when the country is `SA`; up to 20 characters elsewhere | | `address.postalCode` | 5 digits when the country is `SA`; up to 20 characters elsewhere | | `company.creditLimit`, `company.creditPeriodDays` | in the reply only; the merchant sets them on the dashboard. A credit limit of 0 means no limit. | #### ID types When a company has no VAT number, or as well as one, `idType` says what kind of number `idValue` is. These are ZATCA's codes for a buyer's ID: | `idType` | Number | |---|---| | `CRN` | Commercial registration number | | `MOM` | Licence from the Ministry of Municipal and Rural Affairs | | `MLS` | Licence from the Ministry of Human Resources | | `700` | 700 number | | `SAG` | Investment licence (formerly SAGIA, now the Ministry of Investment) | | `NAT` | National ID | | `GCC` | GCC ID | | `IQA` | Iqama (residence permit) | | `PAS` | Passport | | `OTH` | Any other ID | ### Fields every customer takes | Field | Rule | |---|---| | `type` | `person` or `company`; required on create and never changes | | `phone` | E.164; no other customer may have it (`409 phone_taken`) | | `firstName`, `lastName` | a person's name, up to 100 characters each | | `email` | a valid address, up to 256 characters | | `referenceId` | your own id for the customer, up to 100 characters; no other customer may have it (`409 reference_id_taken`); set once | | `branchId` | the branch the customer belongs to; a branch your key may use (`403 branch_not_allowed`) | | `isActive` | `true` unless you send `false`; a disabled customer's invoices and payments answer `422 customer_inactive` | | `metadata` | up to 20 keys of up to 40 characters, values up to 500 characters | When a value is already taken, the error's `existingId` is the customer that holds it. Use it instead of creating a second customer: ```json { "error": { "type": "invalid_request_error", "code": "phone_taken", "message": "Another customer already has this phone number.", "param": "phone", "docUrl": "https://docs.kosoor.com.sa/errors#phone_taken", "existingId": "0f8fad5b-d9cb-469f-a165-70867728950e" } } ``` ### Find a customer `GET /v1/customers` takes exact-match filters, combined with AND: | Parameter | Matches | |---|---| | `phone` | the phone, in E.164 | | `vatNumber` | `company.vatNumber` | | `idValue` | `company.idValue` | | `referenceId` | your own id | Encode the `+` of a phone as `%2B`. A bare `+` in a query string reads as a space. ```bash curl "https://api.kosoor.com.sa/v1/customers?phone=%2B966501234567" \ -H "Authorization: Bearer ksr_test_..." ``` ```json { "data": [ { "id": "0f8fad5b-d9cb-469f-a165-70867728950e", "object": "customer", "type": "person", "referenceId": "crm-88412", "firstName": "Sara", "lastName": "Al-Harbi", "phone": "+966501234567", "email": "sara@example.com", "isActive": true, "branchId": null, "company": null, "address": { "street": null, "buildingNumber": null, "postalCode": null, "district": null, "city": null, "country": null }, "metadata": {}, "standardInvoiceMissing": null, "createdAt": "2026-11-07T09:30:12.512Z" } ], "hasMore": false } ``` An empty `data` means no customer matches. Without filters, the list pages through every customer, newest first; see [Pagination](https://docs.kosoor.com.sa/pagination.md). ### Update a customer `PATCH /v1/customers/{id}` changes only the fields the body carries; every other field keeps its value. Inside `company` and `address`, too, only the fields you send change. ```bash curl -X PATCH https://api.kosoor.com.sa/v1/customers/3f1c2a6e-8b4d-4e2a-9c1f-5d7e8a9b0c12 \ -H "Authorization: Bearer ksr_test_..." \ -H "Idempotency-Key: 8d7c6b5a-4f3e-4d2c-9b1a-0f9e8d7c6b5a" \ -H "Content-Type: application/json" \ -d '{ "email": "accounts@alnoor.example", "address": { "street": "King Abdulaziz Road" } }' ``` The reply is the whole customer, as `GET` returns it. | You send | Result | |---|---| | a field with a value | the field takes it | | `email` or `phone` as `null`, or a person's `firstName` or `lastName` | the field is cleared; a person's phone cannot be cleared | | `metadata` | replaces the whole map; `{}` or `null` empties it | | `type` | must equal the current type; a customer never changes type | | `referenceId` | accepted once, while the customer has none; after that, a different value answers `400 invalid_parameter` | | `company` or `address` as `null` | `400 invalid_parameter`: send the fields to change instead | | `isActive` as `null` | `400 invalid_parameter`: send `true` or `false` | A request that breaks any rule changes nothing. Source: https://docs.kosoor.com.sa/customers --- ## Sales invoices > Issuing a sales invoice in one call: required fields, standard or simplified, prices and VAT, payment at issue, and reading invoices. `POST /v1/sales-invoices` creates and issues a sales invoice in one call. There is no draft step: an issued invoice is corrected only by a credit note. > **Discounts never go below the item's minimum price** > > If an item has a minimum selling price in Kosoor, a `unitDiscount` that would sell a unit below it is **reduced** to that price. The request still succeeds, with no error. > > For example, an item priced 100 SAR before VAT with a minimum price of 92 SAR including VAT (80 SAR before VAT) can take at most 20 SAR off. Send `unitDiscount: 50` and the invoice applies 20. > > Always read `lines[].unitDiscount` and `total` from the reply, not from your own request. If you send `payment.amount`, compare it with the reply's `total`. ### Required fields Every request sends `customerId`, `branchId`, `pricesIncludeVat` and `lines`, and each line sends `itemId` and `quantity`. When you send `payment`, it needs `paymentMethodId`. Every invoice names a customer: the API makes no walk-in sale. ### Standard or simplified The customer decides the invoice's kind: | Customer | Invoice | ZATCA | |---|---|---| | company | standard (tax invoice) | cleared **before** the reply | | person | simplified | reported after the reply | For a standard invoice: - ZATCA accepts it: `201` with `zatca.status: "cleared"` and ZATCA's QR code. - ZATCA rejects it: `422 zatca_rejected` with ZATCA's messages in `zatcaErrors`. Nothing is saved. - ZATCA does not answer in time: `201` with `zatca.status: "pending"`. Kosoor sends it again later, and it turns `cleared`, or `rejected` with ZATCA's messages in `zatca.errors`. Either way the invoice stays issued. A standard invoice also needs the company customer's full address and VAT number or ID (`customer_address_incomplete` lists what is missing), and a branch registered with ZATCA (`branch_not_onboarded`). Its PDF and XML answer `409 invoice_not_cleared` until ZATCA clears it, and for good if ZATCA rejects it. A simplified invoice's PDF is ready at once; its XML answers `409 invoice_not_cleared` until ZATCA has accepted the report, and for good if ZATCA rejects it. ### Prices `pricesIncludeVat` says whether every `unitPrice` and `unitDiscount` in the request includes VAT. A line without `unitPrice` takes the item's catalogue price; an item priced per car size has no single catalogue price, so its line must send `unitPrice` or the call answers `400 missing_parameter` on `lines[i].unitPrice`. Kosoor keeps every price to the halala before VAT, and rounds VAT once per rate on the invoice, the way ZATCA totals it. A price that includes VAT is converted first, so the total can differ from what you sent by a halala: 12.00 including VAT becomes 10.43 before VAT and a total of 11.99. The reply always shows line amounts before VAT. To pay such an invoice in full, send `payment` without `amount`. ### Payment | You send | The invoice | |---|---| | no `payment` | goes on the customer's account (company customers only; a person gets `422 on_account_not_allowed`) | | `payment` without `amount` | is paid in full | | `payment.amount` below the total | is partly paid; the rest stays on the customer's account (company customers only; a person gets `422 on_account_not_allowed`) | | `payment.amount` above the total | is refused: `400 invalid_parameter` | `payment.amount` takes at most two decimals. `amountPaid` and `amountDue` in the reply show where the invoice stands. ### Reading invoices `GET /v1/sales-invoices` lists every issued invoice in the key's branches, whichever channel created it. `expand=customer` adds the customer, and needs `customers:read` as well as `sales_invoices:read`. Source: https://docs.kosoor.com.sa/sales-invoices --- ## Payments > Recording a payment a customer made, allocating it to invoices, the refusals, and reading payments. `POST /v1/payments` records money a customer paid you and issues the receipt in one call. There is no draft step: an issued payment is never cancelled, and money going back to the customer is a credit note. ### Required fields Every request sends `customerId`, `branchId`, `paymentMethodId` and `amount`. `amount` is above 0 with at most two decimals. `branchId` is the branch that received the money. The payment method posts to the treasury set for that branch, or to the method's default treasury when the branch has none. If that treasury serves only another branch, the payment would be recorded there, so it is refused with `422 payment_method_not_in_branch`. `date` is optional and defaults to today in Saudi time. A past date is accepted unless the merchant has closed that accounting period (`422 period_locked`), or it is before an invoice the payment settles (`400 invalid_parameter` on `date`). `notes` takes up to 500 characters and `referenceId` up to 100. ### Allocations `allocations` says which invoices the payment settles, up to 100 of them: ```json { "customerId": "3f1c2a6e-8b4d-4e2a-9c1f-5d7e8a9b0c12", "branchId": "9a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d", "paymentMethodId": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9", "amount": 150, "allocations": [{ "invoiceId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", "amount": 115 }] } ``` | Rule | Error | |---|---| | The allocations add up to no more than `amount` | `400 invalid_parameter` on `allocations` | | An invoice appears once | `400 invalid_parameter` on `allocations[i].invoiceId` | | The invoice exists, is an issued sales invoice of this customer, and is in a branch your key may use | `422 invoice_not_payable` on `allocations[i].invoiceId` | | The payment's `date` is not before the invoice's | `400 invalid_parameter` on `date` | | The amount is no more than the invoice still owes | `422 allocation_exceeds_due` on `allocations[i].amount` | Whatever the allocations leave, or the whole amount when you send none, stays as the customer's credit. The reply shows it as `unallocatedAmount`. After the call, the invoice's `amountPaid` and `amountDue` in `GET /v1/sales-invoices/{id}` include the allocation. > **A key limited to some branches settles only invoices of those branches** > > A key limited to some branches can allocate a payment only to invoices issued in those branches. An invoice from any other branch answers `422 invoice_not_payable`, exactly like an invoice id that does not exist, so the key learns nothing about it. > > To settle an invoice of another branch, record the payment with a key that may use that branch. ### Other refusals | Error | When | |---|---| | `422 referenced_resource_not_found` | the customer, the branch or the payment method does not exist, or the payment method is disabled; `param` names the field | | `422 customer_inactive` | the customer is disabled | | `422 payment_method_not_in_branch` | the payment method's treasury serves another branch | | `409 invoice_busy` on `allocations[i].invoiceId` | another request on that invoice held it for more than 30 seconds; send the request again | | `409 invoice_busy` with no `param` | other payments were being recorded for more than 30 seconds; send the request again | | `409 reference_id_taken` | another payment already has this `referenceId`; `existingId` names it when your key may read it | | `422 payment_not_recorded` | a business rule with no code of its own refused the payment; the message names the rule | A refused payment records nothing. ### Reading payments `GET /v1/payments` lists every issued payment in the key's branches, whichever channel recorded it. `createdVia` says which. Filter with `customerId`, `branchId`, `invoiceId`, `referenceId`, `dateFrom` and `dateTo`; `invoiceId` returns the payments with an allocation to that invoice. The list includes a payment taken with a sale (`POST /v1/sales-invoices` with `payment`) to a company customer. A sale to a person paid at issue keeps the money on the invoice: its `amountPaid` shows it, and no payment is created. A payment recorded on the dashboard can carry a settlement discount: debt the merchant forgave on top of the money. The reply shows it as `discount`, and its allocations can add up to `amount` plus `discount`. `unallocatedAmount` is `amount` plus `discount`, less the allocations. A payment recorded through the API always has a `discount` of 0. A payment recorded with no branch has `branchId: null`, and only a key with every branch sees it. Source: https://docs.kosoor.com.sa/payments --- ## Credit notes > Crediting an issued sales invoice: required fields, refund or balance credit, how much can be credited, ZATCA, refusals, and reading credit notes. `POST /v1/credit-notes` credits items of an issued sales invoice and issues the credit note in one call. There is no draft step, and an issued credit note is never cancelled. The credited goods go back to stock. ### Required fields Every request sends `invoiceId`, `reason` and `lines`, with an [`Idempotency-Key`](https://docs.kosoor.com.sa/idempotency.md) header like every `POST`: ```json { "invoiceId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", "reason": "refund", "lines": [{ "itemId": "7c8d9e0f-1a2b-4c3d-8e4f-5a6b7c8d9e0f", "quantity": 1 }], "refund": { "paymentMethodId": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9" } } ``` The credit note takes its branch, its customer and its prices from the invoice. Each line names an item sold on that invoice, once, with a whole `quantity` no larger than what is left of that item to credit. A unit is credited at its share of what the invoice charged for the item. On an invoice issued through the API, each line is rounded to the halala the way ZATCA totals the credit note, so crediting units one at a time can come to a halala more than crediting them together. Such a credit note may therefore pass what is left of the invoice by one halala for each credit line already issued on it, and always by at least one halala, so the last units can always be credited. The full reply is in the [API reference](https://docs.kosoor.com.sa/api/create-credit-note.md). `reason` is sent to ZATCA as the reason for the credit note: | `reason` | Use it when | |---|---| | `cancellation` | the sale is cancelled, fully or in part | | `refund` | goods or services are returned and the money is given back | | `informationChange` | the invoice's details were wrong and are being corrected | `notes` takes up to 500 characters and is printed on the credit note. `referenceId` takes up to 100 characters and must not be used by any other invoice or credit note of the merchant. ### Paying the money back With `refund`, the credit note's total is paid back through `refund.paymentMethodId`, out of that method's treasury for the invoice's branch: - A method whose treasury serves only another branch is refused (`422 payment_method_not_in_branch`). - The refund cannot be more than the invoice has collected, less what was already paid back on it (`422 refund_exceeds_collected`). Without `refund`, the total comes off what the customer owes. The sales invoice's `amountDue` drops by it, never below 0, and whatever is left over stays as the customer's credit. Only a customer who can hold a balance can be credited this way: - A company customer always can. - An individual customer carries no balance, so they can be credited this way only up to what the invoice still owes. Above that, send `refund` (`400 invalid_parameter` on `refund`). - A sale with no customer needs `refund` (`400 invalid_parameter` on `refund`). This holds for a sale paid by card at the POS too: without `refund`, the credit goes to the customer's balance, and nothing is refunded to the card. The reply shows the money paid back as `refund.amount`, or `refund: null` when the credit went to the customer's balance. ### How much can be credited The credits on an invoice never add up to more than its total, whatever channel issued them. Refunds made at the POS or on the dashboard, sales returns, and credit notes issued through the API all count against the same total, and against the quantity left of each item. A credit above what is left answers `422 credit_exceeds_invoice`: - `param` is `lines[i].quantity` when that line asks for more than is left of its item. - `param` is `lines` when the credit note's total is more than is left to credit on the invoice. Two credit notes for the same invoice sent at the same moment are issued one after the other, so the second one is checked against what the first one left. If the first one is still being issued after 30 seconds, for example while it waits for ZATCA, the second answers `409 invoice_busy`. Send it again. ### ZATCA A credit note is the same kind of ZATCA document as its invoice: - **Standard invoice**: - The credit note is cleared by ZATCA before the reply. - If ZATCA rejects it, nothing is saved and the reply is `422 zatca_rejected` with ZATCA's messages in `zatcaErrors`. - If ZATCA does not answer in time, the reply is `201` with `zatca.status: "pending"`. Kosoor sends it again later, and it becomes `cleared`, or `rejected` with ZATCA's messages in `zatca.errors`. Either way the credit note stays issued. - An invoice ZATCA has not cleared yet cannot be credited (`409 invoice_not_cleared` on `invoiceId`). Retry once `GET /v1/sales-invoices/{id}` shows `zatca.status` as `cleared`; that read needs `sales_invoices:read`. A `4xx` reply is not stored, so the retry may keep its `Idempotency-Key`. - **Simplified invoice**: the credit note is reported to ZATCA after the reply. `zatca.status` is `pending` until then, and becomes `reported`, or `rejected` with ZATCA's messages in `zatca.errors`. An invoice ZATCA rejected cannot be credited through the API (`422 invoice_not_creditable`). Correct the sale with the merchant first. `GET /v1/credit-notes/{id}/pdf` and `GET /v1/credit-notes/{id}/xml` return the credit note's documents: - A standard credit note's PDF and XML answer `409 invoice_not_cleared` until ZATCA clears it, and for good if ZATCA rejects it. - A simplified credit note's PDF is ready straight away. Its XML answers `409 invoice_not_cleared` until ZATCA has accepted the report, and for good if ZATCA rejects it. ### Refusals | Error | When | |---|---| | `422 referenced_resource_not_found` | the invoice does not exist or is in a branch your key may not use, or the refund's payment method does not exist or is disabled; `param` names the field | | `422 invoice_not_creditable` | the invoice is not an issued sales invoice, it was imported, or ZATCA rejected it | | `409 invoice_not_cleared` on `invoiceId` | the invoice is standard and ZATCA has not cleared it yet | | `400 invalid_parameter` on `lines[i].itemId` | the item was not sold on this invoice | | `422 credit_exceeds_invoice` | a line asks for more than is left of its item, or the total is more than is left to credit | | `400 invalid_parameter` on `refund` | no `refund` was sent, and the customer cannot hold the credit as a balance | | `422 payment_method_not_in_branch` | the refund's payment method pays out of a treasury that serves only another branch | | `422 refund_exceeds_collected` | the refund is more than the invoice has collected | | `422 used_subscription_not_refundable` | the invoice sold a subscription whose visits are already used | | `422 refund_method_not_supported` | the sale was paid by card on the merchant's online store; a card refund cannot be started through the API | | `422 loyalty_refund_blocked` | the loyalty platform blocked the credit: points or stamps earned on the sale were already spent, the platform could not be reached, or it has not received the sale yet; the message says which. Retry once a few minutes later; if it is refused again, the merchant can issue it from the dashboard, where a supervisor may override | | `409 reference_id_taken` | another invoice or credit note already has this `referenceId`; `existingId` names it only when it is a credit note your key may read | | `409 invoice_busy` on `invoiceId` | another request held the invoice for more than 30 seconds; send the request again | | `422 zatca_rejected` | ZATCA rejected the credit note of a standard invoice; nothing is saved, and ZATCA's messages are in `zatcaErrors` | | `422 credit_note_not_issued` | a business rule with no code of its own refused the credit note; the message names the rule | When a request is refused, no credit note is issued and nothing is saved. ### Reading credit notes `GET /v1/credit-notes` lists every issued credit note in the key's branches, whichever channel issued it, including refunds made at the POS or on the dashboard. `createdVia` says which. Filter with `invoiceId`, `customerId`, `branchId`, `referenceId`, `issuedAfter`, `issuedBefore` and `zatcaStatus`. Sales returns and credit notes made on the dashboard's accounting screens are not listed, though they count against what is left to credit on the invoice. `refund.paymentMethodId` is null when the refund named no payment method, as POS refunds usually do: the money went back through the branch's own POS, or to the card the sale took. A POS refund split between cash and card shows one refund with the two added together. A refund made before credit notes recorded a reason shows `reason: "cancellation"`, the reason it was sent to ZATCA with. Source: https://docs.kosoor.com.sa/credit-notes --- ## Authentication > How to send your API key, the permissions a key can carry, branch limits, and why a key stops working. Send the key as a Bearer token on every request: ```http Authorization: Bearer ksr_live_3f9a... ``` A key is `ksr_live_` followed by 64 hexadecimal characters; a test key starts with `ksr_test_` instead and reaches only the sandbox (see [Test mode](https://docs.kosoor.com.sa/test-mode.md)). Keep it on your server: anyone holding it acts for the merchant. ### Permissions The merchant grants each key a level per resource, written `resource:level`: | Permission | Allows | |---|---| | `customers:read` | listing and reading customers | | `customers:write` | the above, plus creating and updating customers | | `sales_invoices:read` | listing and reading sales invoices, with their PDF and XML | | `sales_invoices:write` | the above, plus issuing sales invoices | | `payments:read` | listing and reading payments | | `payments:write` | the above, plus recording payments | | `credit_notes:read` | listing and reading credit notes, with their PDF and XML | | `credit_notes:write` | the above, plus issuing credit notes | `write` includes `read`. A call the key is not allowed to make answers `403 missing_permission`, and the error's `requiredPermission` names the permission it needs. More resources join as the API grows. ### Branches A key may be limited to some of the merchant's branches. A call that names another branch answers `403 branch_not_allowed`. ### When a key stops working | Code | Status | Why | |---|---|---| | `missing_api_key` | 401 | no `Authorization: Bearer` header | | `invalid_api_key` | 401 | the key is malformed or unknown | | `revoked_api_key` | 401 | the merchant revoked it | | `expired_api_key` | 401 | its expiry date has passed | | `tenant_suspended` | 403 | the merchant's Kosoor account is suspended | None of these is fixed by retrying: ask the merchant for a new key or to restore its account. Source: https://docs.kosoor.com.sa/authentication --- ## Retries and idempotency > How the Idempotency-Key header makes a retried POST or PATCH safe, what a retry gets back, and when to retry. A network can fail after Kosoor has saved your request but before you read the answer. Retrying then must not create a second customer or invoice. Every `POST`, `PATCH` and `DELETE` therefore carries an `Idempotency-Key` header, and a retry sends the same key. ```bash curl https://api.kosoor.com.sa/v1/customers \ -H "Authorization: Bearer ksr_live_..." \ -H "Idempotency-Key: 9b2c4c0e-6a51-4c0b-8a43-3c2f2f1a7d10" \ -H "Content-Type: application/json" \ -d '{"type":"person","firstName":"Sara","phone":"+966501234567"}' ``` ### The key - 1 to 255 printable ASCII characters, matched case-sensitively. A UUID per logical operation works well. - Keys belong to your API key: another key may use the same value without colliding. - Kosoor keeps a key for 30 days. ### What a retry gets | You send | Kosoor answers | |---|---| | the same key with the same method, path and body, after a success | the stored reply, with `Idempotent-Replayed: true`; nothing runs again | | the same key with a different request | `422 idempotency_key_reused` | | the same key while the first request is still running | `409 idempotency_in_progress` with `Retry-After: 1` | | the same key after a failure | the request runs again: only successful replies are stored | | no key | `400 missing_idempotency_key` | A request that died mid-way holds its key for 5 minutes; after that a retry with the same key runs it. ### When to retry Retry with the same key on a timeout, a dropped connection, `409 idempotency_in_progress`, `429 rate_limited` once `Retry-After` has passed, and any `5xx`. Do not retry a `4xx` unchanged: it will answer the same way. Fix the request and send it under a new key. Three refusals depend on time instead, and are retried later with the same key, since no `4xx` reply is stored: `409 invoice_not_cleared`, once ZATCA has cleared the invoice; `409 invoice_busy`, after a few seconds; and `422 loyalty_refund_blocked`, once, a few minutes later. If it is refused again, the merchant issues the credit note from the dashboard, where a supervisor may override. Source: https://docs.kosoor.com.sa/idempotency --- ## Pagination > How lists return the newest records first, one page at a time, with limit, startingAfter and hasMore. List endpoints return the newest records first, one page at a time. ```bash curl "https://api.kosoor.com.sa/v1/customers?limit=50" \ -H "Authorization: Bearer ksr_live_..." ``` ```json { "data": [ { "id": "0f8fad5b-d9cb-469f-a165-70867728950e" } ], "hasMore": true } ``` | Parameter | Meaning | |---|---| | `limit` | 1 to 100; 20 when omitted | | `startingAfter` | the `id` of the last record of the previous page | While `hasMore` is `true`, ask for the next page with `startingAfter` set to the last `id` you received. A record added while you page appears at the top of the list, so it never shifts the pages you are reading. Source: https://docs.kosoor.com.sa/pagination --- ## Webhooks > Have Kosoor push ZATCA results to your system as they arrive, verify each request, and handle retries and duplicates. A sales invoice or credit note that answers `zatca.status: "pending"` gets its ZATCA result later. Instead of polling for it, register a webhook endpoint: Kosoor sends your system an HTTPS `POST` the moment the result is in. Kosoor follows [Standard Webhooks](https://www.standardwebhooks.com/), so the open-source `standardwebhooks` libraries verify its requests as they are. ### Register an endpoint ```bash curl https://api.kosoor.com.sa/v1/webhook-endpoints \ -H "Authorization: Bearer ksr_live_..." \ -H "Idempotency-Key: 6f1d2c7e-hooks-erp" \ -H "Content-Type: application/json" \ -d '{"url":"https://erp.example.com/kosoor/webhooks","events":["salesInvoice.zatcaCleared","salesInvoice.zatcaReported","salesInvoice.zatcaRejected"]}' ``` ```json { "id": "4d7b2e91-3c6a-4f85-9e1d-0a8c5b7f2e63", "object": "webhookEndpoint", "url": "https://erp.example.com/kosoor/webhooks", "events": ["salesInvoice.zatcaCleared", "salesInvoice.zatcaReported", "salesInvoice.zatcaRejected"], "status": "enabled", "disabledReason": null, "secret": "whsec_IhPDzwacK7vjZSdgdWIEKuvhvZqOrOozQwbRj2XIT9w=", "createdAt": "2026-11-07T09:30:00Z" } ``` **Store `secret` now.** It appears only in this reply and in the reply to a secret rotation; you need it to verify every request. - The key needs `webhooks:write` to manage endpoints (`webhooks:read` to list them), and read access to what the events are about: `sales_invoices:read` for `salesInvoice.*`, `credit_notes:read` for `creditNote.*`. - An endpoint belongs to the key that created it. Other keys do not see it, and it stops receiving when that key is revoked or expires. A key limited to some branches only receives events for documents in those branches. - A test key's endpoints live in the sandbox and receive only sandbox events. They survive a sandbox reset. - The URL must be `https`, without a user name or password, and its host must resolve to a public address. - A key holds at most 10 endpoints; the 11th answers `422 webhook_endpoint_limit`. | | | |---|---| | `GET /v1/webhook-endpoints` | list the key's endpoints (no secrets) | | `GET /v1/webhook-endpoints/{id}` | read one | | `DELETE /v1/webhook-endpoints/{id}` | remove it; anything still waiting for it is cancelled | | `POST /v1/webhook-endpoints/{id}/rotate-secret` | issue a new secret (see [Rotating the secret](#rotating-the-secret)) | | `POST /v1/webhook-endpoints/{id}/test` | send a `webhookEndpoint.test` event now | Every `POST` and `DELETE` carries an `Idempotency-Key`, as on the rest of the API. ### Events | Event | Sent when | |---|---| | `salesInvoice.zatcaCleared` | ZATCA cleared a standard (B2B) invoice that had answered `pending` | | `salesInvoice.zatcaReported` | ZATCA accepted the report of a simplified (B2C) invoice | | `salesInvoice.zatcaRejected` | ZATCA rejected the invoice | | `creditNote.zatcaCleared` | the same three, for a credit note | | `creditNote.zatcaReported` | | | `creditNote.zatcaRejected` | | | `webhookEndpoint.test` | you called `POST /v1/webhook-endpoints/{id}/test`; sent whatever the endpoint subscribes to | A result the creating call already returned (a standard invoice cleared at issue) raises no event: you have it in the reply. ### What Kosoor sends ```http POST /kosoor/webhooks HTTP/1.1 Content-Type: application/json User-Agent: Kosoor-Webhooks/1.0 webhook-id: 019a5f3c-7e21-7b4d-8a6f-2c9e1d0b5a74 webhook-timestamp: 1794043800 webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= ``` ```json { "id": "019a5f3c-7e21-7b4d-8a6f-2c9e1d0b5a74", "object": "event", "type": "salesInvoice.zatcaReported", "createdAt": "2026-11-07T09:31:12Z", "data": { "id": "5c8a1f9e-2d7b-4c3a-9e6f-1a4b8d0c7e52", "object": "salesInvoice", "number": "INV-10045", "referenceId": "order-10045", "zatca": { "kind": "simplified", "status": "reported", "uuid": "b7c1e0d2-4f3a-4c8e-9a1b-2d5e6f7a8b9c", "counter": 1287, "hash": "...", "qr": "...", "submittedAt": "2026-11-07T09:31:10Z", "clearedAt": null, "reportedAt": "2026-11-07T09:31:12Z", "errors": [] } } } ``` - `data` names the document and carries its `zatca` block, the same block `GET` returns. Read the whole document with `GET /v1/sales-invoices/{id}` or `GET /v1/credit-notes/{id}` when you need more. - `id` is the event's id. It equals the `webhook-id` header and stays the same on every retry. - `createdAt` is when the event was raised, in UTC; `webhook-timestamp` is when this attempt was sent, in Unix seconds. ### Verify every request Anyone can `POST` to your URL. Check the signature before you trust the body, and check it against the **raw body bytes**: a JSON parser that re-serializes the body changes them. The signature is HMAC-SHA256, keyed by the secret's bytes (the part after `whsec_`, base64-decoded), over `webhook-id`, `webhook-timestamp` and the body joined by dots. `webhook-signature` holds one or more `v1,` values separated by spaces; accept the request when any of them matches. Refuse a `webhook-timestamp` more than 5 minutes from your clock, so a captured request cannot be replayed later. #### Node.js ```bash npm install standardwebhooks ``` ```js import express from 'express'; import { Webhook } from 'standardwebhooks'; const webhook = new Webhook(process.env.KOSOOR_WEBHOOK_SECRET); // "whsec_..." const app = express(); app.post('/kosoor/webhooks', express.raw({ type: 'application/json' }), (req, res) => { let event; try { event = webhook.verify(req.body, req.headers); // checks the signature and the 5-minute window } catch { return res.status(400).end(); } res.status(204).end(); // answer first queueForProcessing(event); // then work }); ``` #### Python ```bash pip install standardwebhooks ``` ```python import os from flask import Flask, request from standardwebhooks import Webhook webhook = Webhook(os.environ["KOSOOR_WEBHOOK_SECRET"]) # "whsec_..." app = Flask(__name__) @app.post("/kosoor/webhooks") def kosoor_webhook(): try: event = webhook.verify(request.get_data(), dict(request.headers)) except Exception: return "", 400 queue_for_processing(event) return "", 204 ``` #### C# (ASP.NET Core) ```csharp using System.Security.Cryptography; using System.Text; app.MapPost("/kosoor/webhooks", async (HttpRequest request) => { using var reader = new StreamReader(request.Body, Encoding.UTF8); var body = await reader.ReadToEndAsync(); var id = request.Headers["webhook-id"].ToString(); var timestamp = request.Headers["webhook-timestamp"].ToString(); var signatures = request.Headers["webhook-signature"].ToString().Split(' '); if (!long.TryParse(timestamp, out var seconds) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - seconds) > 300) return Results.BadRequest(); var secret = Environment.GetEnvironmentVariable("KOSOOR_WEBHOOK_SECRET")!; // "whsec_..." var key = Convert.FromBase64String(secret["whsec_".Length..]); var expected = Encoding.UTF8.GetBytes( "v1," + Convert.ToBase64String(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{id}.{timestamp}.{body}")))); if (!signatures.Any(s => CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(s), expected))) return Results.BadRequest(); QueueForProcessing(body); return Results.NoContent(); }); ``` #### Check it by hand To see the calculation with nothing but a shell: ```bash SECRET="whsec_IhPDzwacK7vjZSdgdWIEKuvhvZqOrOozQwbRj2XIT9w=" ID="019a5f3c-7e21-7b4d-8a6f-2c9e1d0b5a74" TS="1794043800" BODY='{"id":"019a5f3c-7e21-7b4d-8a6f-2c9e1d0b5a74","object":"event",...}' KEY_HEX=$(echo -n "${SECRET#whsec_}" | base64 -d | xxd -p -c 256) printf 'v1,%s\n' "$(printf '%s.%s.%s' "$ID" "$TS" "$BODY" \ | openssl dgst -sha256 -mac HMAC -macopt hexkey:"$KEY_HEX" -binary | base64)" ``` The output is what `webhook-signature` carries for that body. ### Answer fast, then work - Answer any `2xx` within **15 seconds**. A slower answer counts as a failure and the event is sent again. - Do the work after answering: put the event on a queue, answer `204`, process it from the queue. - Redirects are not followed: a `3xx` counts as a failure. Register the final URL. ### Retries An attempt fails when your endpoint answers anything but `2xx`, takes more than 15 seconds, or cannot be reached. Kosoor then tries again after: | After attempt | Wait | |---|---| | 1 | 5 seconds | | 2 | 5 minutes | | 3 | 30 minutes | | 4 | 2 hours | | 5 | 5 hours | | 6 | 10 hours | | 7 | 14 hours | | 8 | 20 hours | | 9 | 24 hours | Each wait is lengthened by up to 10% at random, so a backlog does not arrive all at once. After the 10th attempt, about 3 days after the first, the event is marked failed. Answer `429` or `503` with a `Retry-After` header and Kosoor waits at least that long before the next attempt. ### Duplicates and order - **The same event can arrive twice**: when your answer was lost on the way back, or when it was sent again from the dashboard. Keep the `webhook-id` of every event you processed and skip one you have already seen. - **Events can arrive out of order.** Retries overlap with newer events. Decide what to do from the document's current `zatca.status` (read it with `GET` when in doubt), not from the order events arrive in. ### When Kosoor stops sending | `disabledReason` | Why | |---|---| | `gone` | your endpoint answered `410 Gone`: Kosoor stops at once | | `failing` | nothing was delivered for 5 days | | `merchant` | the merchant switched it off in the dashboard | A stopped endpoint shows `status: "disabled"` on `GET`, and what it had waiting is cancelled. The merchant can switch it back on from **Developers → Webhooks** in the dashboard, where every delivery is listed with its attempts, the last answer your endpoint gave, and a **Resend** button that sends it again under the same `webhook-id`. ![The Webhooks tab: endpoints, then every event sent with its attempts and last answer](https://docs.kosoor.com.sa/img/onboarding/webhooks.en.png.md) ### Rotating the secret ```bash curl -X POST https://api.kosoor.com.sa/v1/webhook-endpoints/4d7b2e91-3c6a-4f85-9e1d-0a8c5b7f2e63/rotate-secret \ -H "Authorization: Bearer ksr_live_..." \ -H "Idempotency-Key: rotate-2026-11" ``` The reply carries the new `secret`. For the next **24 hours** Kosoor signs every request with both the old and the new secret, so `webhook-signature` carries two `v1,` values. Deploy the new secret within that window; a verifier holding either one keeps working. ### Try it 1. Create a test key and an endpoint pointing at a URL you can watch, such as a request-bin service. 2. `POST /v1/webhook-endpoints/{id}/test`: the reply's `id` is the `webhook-id` you receive. 3. Subscribe to `salesInvoice.zatcaCleared` and sell to the sandbox customer **Sandbox ZATCA Pending Co**: the invoice answers `pending`, ZATCA's test environment clears it shortly after, and the event arrives. See [Test mode](https://docs.kosoor.com.sa/test-mode.md). Source: https://docs.kosoor.com.sa/webhooks --- ## Test mode > Building against the merchant's sandbox with a test key: what it holds, when it is busy, and the fixture customers with known outcomes. Build against a sandbox before you touch the merchant's real data. A test key works exactly like a live one, on the same host and paths, but every call reaches the merchant's sandbox: a separate copy of their catalogue whose invoices are signed with ZATCA's test certificates and never reach their real ZATCA records. ### Getting a test key The merchant creates the sandbox in the dashboard under **Settings → Developers → Sandbox**, then issues a test key under **Settings → Developers → API keys**. A test key starts with `ksr_test_`: ```http Authorization: Bearer ksr_test_7c1e... ``` Every reply to a test key carries the header `Kosoor-Mode: test`. Check it in your client if you want to be sure a call did not reach live data. ### What the sandbox holds - The merchant's branches, items, payment methods, chart of accounts and warehouses, copied when the sandbox is built. - None of their customers, invoices or payments. Create your own, or use the fixtures below. - The merchant's name and address, with ZATCA's test VAT number `399999999900003`. PDFs from the sandbox carry a red **Test, not a tax invoice** mark. The sandbox sends no SMS. ### When the sandbox is busy While the sandbox is being built or reset, a test key answers `503 sandbox_unavailable` with a `Retry-After: 30` header. Wait and send the same request again. If the sandbox could not be built, a test key answers `503 sandbox_failed` with no `Retry-After`: retrying will not help until the merchant resets the sandbox. While the sandbox is being deleted, its test keys answer `401 invalid_api_key`. A reset rebuilds the sandbox from the merchant's current catalogue. Your test keys keep working; every document and the request log are cleared. Deleting the sandbox stops every test key. ### Fixtures Every sandbox holds these customers and one item under fixed ids, each with a known outcome. Use them to exercise your error handling without arranging the data yourself. | Id | Name | Outcome | |---|---|---| | `5a4db0c5-0000-4000-8000-000000000001` | Sandbox Person | a simplified invoice succeeds | | `5a4db0c5-0000-4000-8000-000000000002` | Sandbox Standard Co | a standard invoice is cleared | | `5a4db0c5-0000-4000-8000-000000000003` | Sandbox CRN Co | a company with a commercial registration and no VAT number; a standard invoice is cleared | | `5a4db0c5-0000-4000-8000-000000000004` | Sandbox No Street Co | a standard invoice is refused: `422 customer_address_incomplete` | | `5a4db0c5-0000-4000-8000-000000000005` | Sandbox Gulf Trading | a company in the UAE; a standard invoice is cleared | | `5a4db0c5-0000-4000-8000-000000000006` | Sandbox Inactive Person | any invoice is refused: `422 customer_inactive` | | `5a4db0c5-0000-4000-8000-000000000007` | Sandbox Credit Limit Co | credit limit 100; an invoice on account above it answers `422 credit_limit_exceeded` | | `5a4db0c5-0000-4000-8000-000000000008` | Sandbox ZATCA Pending Co | a standard invoice answers `201` with `zatca.status` `pending`, and is cleared shortly after | | `5a4db0c5-0000-4000-8000-000000000009` | Sandbox ZATCA Rejected Co | a standard invoice is refused: `422 zatca_rejected`, with a sample `BR-KSA-63` in `zatcaErrors` | | `5a4db0c5-0000-4000-8000-000000000101` | Test subscription (item) | a line naming it is refused: `422 item_not_sellable` | Fixtures 8 and 9 never reach ZATCA when the invoice is issued: their answers come from Kosoor, so they are the same every time. Source: https://docs.kosoor.com.sa/test-mode --- ## Error codes > Every error code the Merchant API returns, with its HTTP status and meaning, and the shape of the error body. Every error answers with the same body. Branch on `code`; `message` is for people and changes with `Accept-Language`. ```json { "error": { "type": "invalid_request_error", "code": "phone_taken", "message": "Another customer already has this phone number.", "param": "phone", "docUrl": "https://docs.kosoor.com.sa/errors#phone_taken", "existingId": "0f8fad5b-d9cb-469f-a165-70867728950e" } } ``` | Field | Meaning | |---|---| | `type` | The family the code belongs to, one of the headings below. | | `code` | What went wrong, stable across versions. | | `message` | A sentence in the request's language. | | `param` | The field, query parameter or header at fault, as a path such as `company.vatNumber`; `null` when none is. | | `docUrl` | This page, at the code's own anchor. | | `errors` | On a 400, every problem in the request, the first one included. | | `requiredPermission` | On `missing_permission`, the permission the call needs, such as `customers:write`. | | `existingId` | On a `*_taken` code, the id of the resource that already holds the value, when it is the same kind of resource and your key may read it. | | `missing` | On `customer_address_incomplete`, the customer fields a standard invoice still needs, such as `address.street`. | | `zatcaErrors` | On `zatca_rejected`, ZATCA's own messages, each with its `code`, `category` and `message`. | ### Authentication #### `missing_api_key` HTTP status: **401** No API key was sent. Send it as Authorization: Bearer . #### `invalid_api_key` HTTP status: **401** The API key is not valid. #### `revoked_api_key` HTTP status: **401** This API key has been revoked. #### `expired_api_key` HTTP status: **401** This API key has expired. ### Permissions #### `tenant_suspended` HTTP status: **403** This business's account is suspended. #### `missing_permission` HTTP status: **403** This API key lacks the permission this request needs. #### `branch_not_allowed` HTTP status: **403** This API key cannot act on this branch. ### Invalid request #### `invalid_json` HTTP status: **400** The request body is not valid JSON. #### `missing_parameter` HTTP status: **400** A required parameter is missing. #### `invalid_parameter` HTTP status: **400** A parameter has an invalid value. #### `unknown_parameter` HTTP status: **400** The request has a parameter this endpoint does not accept. #### `too_many_errors` HTTP status: **400** The request has more problems than are listed. #### `unsupported_media_type` HTTP status: **415** Send the request body as application/json. #### `body_too_large` HTTP status: **413** The request body is too large. #### `route_not_found` HTTP status: **404** No endpoint exists at this URL. #### `method_not_allowed` HTTP status: **405** This endpoint does not accept this HTTP method. #### `resource_not_found` HTTP status: **404** The resource in the URL was not found. #### `reference_id_taken` HTTP status: **409** Another resource already has this referenceId. #### `invoice_not_cleared` HTTP status: **409** ZATCA has not accepted this invoice. Check zatca.status. #### `invoice_busy` HTTP status: **409** Another request is changing this invoice. Retry in a few seconds. #### `invalid_vat_number` HTTP status: **400** A VAT number is 15 digits that start and end with 3. #### `vat_number_taken` HTTP status: **409** Another customer already has this VAT number. #### `phone_taken` HTTP status: **409** Another customer already has this phone number. ### Business rules #### `referenced_resource_not_found` HTTP status: **422** The request names a resource that does not exist. #### `invoice_not_issued` HTTP status: **422** The sales invoice was not issued. #### `on_account_not_allowed` HTTP status: **422** An individual customer cannot buy on account. #### `credit_limit_exceeded` HTTP status: **422** Credit limit exceeded. #### `customer_inactive` HTTP status: **422** The customer is inactive. #### `customer_address_incomplete` HTTP status: **422** A standard invoice needs the company customer's full address and VAT number or ID. The missing fields are listed in missing. #### `item_not_sellable` HTTP status: **422** This item cannot be sold on a sales invoice. #### `branch_not_onboarded` HTTP status: **422** This branch is not onboarded with ZATCA, so it cannot issue a standard invoice. #### `zatca_rejected` HTTP status: **422** ZATCA rejected the invoice, so nothing was saved. ZATCA's messages are in zatcaErrors. #### `allocation_exceeds_due` HTTP status: **422** The allocation is more than this invoice still owes. #### `invoice_not_payable` HTTP status: **422** This invoice cannot take a payment: it does not exist, belongs to another customer or to a branch this key cannot use, is not issued, or is not a sales invoice. #### `period_locked` HTTP status: **422** This date is in a closed accounting period. #### `payment_method_not_in_branch` HTTP status: **422** This payment method's treasury serves another branch, so it cannot take a payment for the branch you named. #### `payment_not_recorded` HTTP status: **422** The payment was not recorded. #### `webhook_endpoint_limit` HTTP status: **422** This key already holds the most webhook endpoints it may have. Delete one it does not use first. #### `credit_exceeds_invoice` HTTP status: **422** This credit is more than is left to credit on the invoice. #### `invoice_not_creditable` HTTP status: **422** This invoice cannot be credited: it is not an issued sales invoice, it was imported, or ZATCA rejected it. #### `used_subscription_not_refundable` HTTP status: **422** This subscription has visits used, so it cannot be credited through the API. #### `refund_exceeds_collected` HTTP status: **422** The refund is more than the invoice has collected. #### `refund_method_not_supported` HTTP status: **422** This sale was paid by card online; a refund to the card cannot be started through the API. #### `loyalty_refund_blocked` HTTP status: **422** The loyalty platform blocked this credit: points or stamps earned on this sale were already spent, the platform could not be reached, or it has not received the sale yet. #### `credit_note_not_issued` HTTP status: **422** The credit note was not issued. ### Idempotency #### `missing_idempotency_key` HTTP status: **400** Send an Idempotency-Key header with this request. #### `invalid_idempotency_key` HTTP status: **400** The Idempotency-Key header is not valid. #### `idempotency_key_reused` HTTP status: **422** This Idempotency-Key was already used with a different request. #### `idempotency_in_progress` HTTP status: **409** A request with this Idempotency-Key is still being processed. ### Rate limits Kosoor counts the calls of each API key in a fixed one-minute window, reads (`GET`) apart from writes (every other method): | Key | Reads per minute | Writes per minute | |---|---|---| | Live (`ksr_live_`) | 1,200 | 300 | | Test (`ksr_test_`) | 300 | 60 | Past the limit, a call answers `429 rate_limited` with a `Retry-After` header: the seconds until the next minute starts. Each key has its own limit, so one system cannot use up another's. A refused write claims no idempotency key: send it again after `Retry-After` with the same `Idempotency-Key`. #### `rate_limited` HTTP status: **429** Too many requests. Retry after the time in Retry-After. ### Server #### `internal_error` HTTP status: **500** Something went wrong on our side. Retry, and quote the Request-Id if it keeps happening. #### `sandbox_unavailable` HTTP status: **503** The sandbox is being prepared. Retry shortly. #### `sandbox_failed` HTTP status: **503** The sandbox could not be built. Retrying will not help until the merchant resets it from the dashboard's Developers page. Source: https://docs.kosoor.com.sa/errors --- ## Versioning > What can change in v1 without notice, what never changes, and how a breaking change would arrive. The API's version is the first part of every path: `/v1`. ### What can change in v1 These changes can arrive in v1 at any time, and each is listed in the [Changelog](https://docs.kosoor.com.sa/changelog.md): - new endpoints and new resources, with their own permissions; - new optional request fields and query parameters; - new fields in replies; - new error codes; - new values in fields that name a kind or a channel, such as `createdVia`. So your client must ignore reply fields it does not know, and handle a value it does not know, for example by treating an unknown `createdVia` like `unknown`. ### What does not change in v1 - An existing field keeps its name, its type and its meaning. - An error `code` keeps its meaning. Branch on `code`; `message` is text for people and can be reworded or translated at any time. - A request that works today keeps working. Requests stay strict: a field or query parameter that an endpoint does not know is refused with `400 unknown_parameter`. A new optional field becomes accepted only once it is released and listed in the changelog. ### A breaking change A change that would break a working client, such as removing or renaming a field, comes as a new version with its own path prefix, such as `/v2`. Source: https://docs.kosoor.com.sa/versioning --- ## Changelog > Every change to the Kosoor Merchant API, newest first. Every change to the API is listed here, newest first. See [Versioning](https://docs.kosoor.com.sa/versioning.md) for what can change within v1. ### 7 November 2026: v1 The first version of the Merchant API. - **Customers**: create, read, list and update people and companies; find them by phone, VAT number, ID or your own `referenceId`. Permissions `customers:read` and `customers:write`. See [Customers](https://docs.kosoor.com.sa/customers.md). - **Sales invoices**: issue an invoice in one call, with payment at issue or on account; standard invoices are cleared by ZATCA before the reply. Read and list invoices from every channel, and download their PDF and ZATCA XML. Permissions `sales_invoices:read` and `sales_invoices:write`. See [Sales invoices](https://docs.kosoor.com.sa/sales-invoices.md). - **Payments**: record a payment and allocate it to invoices; read and list payments from every channel. Permissions `payments:read` and `payments:write`. See [Payments](https://docs.kosoor.com.sa/payments.md). - **Credit notes**: credit an issued invoice with a refund or against what the customer owes; read and list credit notes, including refunds made at the POS and on the dashboard; download their PDF and ZATCA XML. Permissions `credit_notes:read` and `credit_notes:write`. See [Credit notes](https://docs.kosoor.com.sa/credit-notes.md). - **Catalogue**: read and list branches, items and payment methods, to fill the IDs an invoice needs. Permissions `branches:read`, `items:read` and `payment_methods:read`. - **Webhooks**: ZATCA results that arrive after the reply are pushed to an HTTPS endpoint the key registers, signed the Standard Webhooks way and retried for about 3 days. Permissions `webhooks:read` and `webhooks:write`. See [Webhooks](https://docs.kosoor.com.sa/webhooks.md). - **Test mode**: a sandbox per merchant, test keys that start with `ksr_test_`, the `Kosoor-Mode: test` header and fixture customers with known outcomes. See [Test mode](https://docs.kosoor.com.sa/test-mode.md). - **Rate limits**: per API key, in a fixed one-minute window, reads and writes counted apart: 1,200 reads and 300 writes for a live key, 300 and 60 for a test key. Past them, `429 rate_limited` with `Retry-After`. See [Rate limits](https://docs.kosoor.com.sa/errors.md#rate_limit_error). - **Downloads**: the [OpenAPI document](https://docs.kosoor.com.sa/openapi.json), and a [Postman collection](https://docs.kosoor.com.sa/postman/kosoor-merchant-api.postman_collection.json) with the [Kosoor test](https://docs.kosoor.com.sa/postman/kosoor-test.postman_environment.json) and [Kosoor live](https://docs.kosoor.com.sa/postman/kosoor-live.postman_environment.json) environments. Source: https://docs.kosoor.com.sa/changelog --- # API reference Every endpoint, generated from the OpenAPI document the API itself publishes. ### Create a customer `POST https://api.kosoor.com.sa/v1/customers` Needs customers:write. The phone, the VAT number and referenceId must each be unused by another customer. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Idempotency-Key` | string | yes | 1 to 255 printable ASCII characters, matched case-sensitively; a retry sends the same key. Kept 30 days. A key still in use answers 409 with Retry-After. Length 1–255. | | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Request body `Content-Type: application/json` | Field | Type | Required | Description | |---|---|---|---| | `type` | string: `person`, `company`, nullable | | Fixed at create. | | `referenceId` | string, nullable | | Your own id for this customer, unique per tenant; set once. | | `firstName` | string, nullable | | | | `lastName` | string, nullable | | | | `phone` | string, nullable | | E.164, e.g. `+966501234567`. | | `email` | string, nullable | | | | `isActive` | boolean, nullable | | | | `branchId` | string (uuid), nullable | | | | `company` | object, nullable | | | | `company.name` | string, nullable | | | | `company.vatNumber` | string, nullable | | 15 digits that start and end with 3; unique per tenant. | | `company.idType` | string: `CRN`, `MOM`, `MLS`, `700`, `SAG`, `NAT`, `GCC`, `IQA`, `PAS`, `OTH`, nullable | | | | `company.idValue` | string, nullable | | | | `address` | object, nullable | | | | `address.street` | string, nullable | | | | `address.buildingNumber` | string, nullable | | | | `address.postalCode` | string, nullable | | | | `address.district` | string, nullable | | | | `address.city` | string, nullable | | | | `address.country` | string, nullable | | ISO 3166 alpha-2; required on a company. | | `metadata` | map of string, nullable | | Up to 20 keys of up to 40 characters, values up to 500. | #### Responses **201** Created (`application/json`) Header `Idempotent-Replayed`: true when this is the stored reply of an earlier request with the same key. | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | string | yes | Always `customer`: says what kind of object this is to code that receives several kinds. | | `type` | string: `person`, `company` | yes | | | `referenceId` | string, nullable | yes | | | `firstName` | string, nullable | yes | | | `lastName` | string, nullable | yes | | | `phone` | string, nullable | yes | | | `email` | string, nullable | yes | | | `isActive` | boolean | yes | | | `branchId` | string (uuid), nullable | yes | | | `company` | object, nullable | yes | | | `company.name` | string, nullable | yes | | | `company.vatNumber` | string, nullable | yes | | | `company.idType` | string, nullable | yes | | | `company.idValue` | string, nullable | yes | | | `company.creditLimit` | number (double) | yes | | | `company.creditPeriodDays` | integer (int32), nullable | yes | | | `address` | object | yes | | | `address.street` | string, nullable | yes | | | `address.buildingNumber` | string, nullable | yes | | | `address.postalCode` | string, nullable | yes | | | `address.district` | string, nullable | yes | | | `address.city` | string, nullable | yes | | | `address.country` | string, nullable | yes | | | `metadata` | map of string | yes | | | `standardInvoiceMissing` | array of string, nullable | yes | The fields a standard invoice still needs; null for a person. | | `createdAt` | string (date-time) | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **409** A conflict: error.code says which. idempotency_in_progress comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **422** Refused by a business rule or a reused Idempotency-Key: error.code says which. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### List customers `GET https://api.kosoor.com.sa/v1/customers` Needs customers:read. Newest first, paged with limit and startingAfter. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `phone` | string | | E.164. | | `vatNumber` | string | | | | `idValue` | string | | The company's `idValue`. | | `referenceId` | string | | | | `limit` | integer (int32) | | 1 to 100; 20 when omitted. | | `startingAfter` | string (uuid) | | The id of the last row of the previous page. | #### Responses **200** OK (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `data` | array of object | yes | | | `data[].id` | string (uuid) | yes | | | `data[].object` | string | yes | Always `customer`: says what kind of object this is to code that receives several kinds. | | `data[].type` | string: `person`, `company` | yes | | | `data[].referenceId` | string, nullable | yes | | | `data[].firstName` | string, nullable | yes | | | `data[].lastName` | string, nullable | yes | | | `data[].phone` | string, nullable | yes | | | `data[].email` | string, nullable | yes | | | `data[].isActive` | boolean | yes | | | `data[].branchId` | string (uuid), nullable | yes | | | `data[].company` | object, nullable | yes | | | `data[].company.name` | string, nullable | yes | | | `data[].company.vatNumber` | string, nullable | yes | | | `data[].company.idType` | string, nullable | yes | | | `data[].company.idValue` | string, nullable | yes | | | `data[].company.creditLimit` | number (double) | yes | | | `data[].company.creditPeriodDays` | integer (int32), nullable | yes | | | `data[].address` | object | yes | | | `data[].address.street` | string, nullable | yes | | | `data[].address.buildingNumber` | string, nullable | yes | | | `data[].address.postalCode` | string, nullable | yes | | | `data[].address.district` | string, nullable | yes | | | `data[].address.city` | string, nullable | yes | | | `data[].address.country` | string, nullable | yes | | | `data[].metadata` | map of string | yes | | | `data[].standardInvoiceMissing` | array of string, nullable | yes | The fields a standard invoice still needs; null for a person. | | `data[].createdAt` | string (date-time) | yes | | | `hasMore` | boolean | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Retrieve a customer `GET https://api.kosoor.com.sa/v1/customers/{id}` Needs customers:read. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | #### Responses **200** OK (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | string | yes | Always `customer`: says what kind of object this is to code that receives several kinds. | | `type` | string: `person`, `company` | yes | | | `referenceId` | string, nullable | yes | | | `firstName` | string, nullable | yes | | | `lastName` | string, nullable | yes | | | `phone` | string, nullable | yes | | | `email` | string, nullable | yes | | | `isActive` | boolean | yes | | | `branchId` | string (uuid), nullable | yes | | | `company` | object, nullable | yes | | | `company.name` | string, nullable | yes | | | `company.vatNumber` | string, nullable | yes | | | `company.idType` | string, nullable | yes | | | `company.idValue` | string, nullable | yes | | | `company.creditLimit` | number (double) | yes | | | `company.creditPeriodDays` | integer (int32), nullable | yes | | | `address` | object | yes | | | `address.street` | string, nullable | yes | | | `address.buildingNumber` | string, nullable | yes | | | `address.postalCode` | string, nullable | yes | | | `address.district` | string, nullable | yes | | | `address.city` | string, nullable | yes | | | `address.country` | string, nullable | yes | | | `metadata` | map of string | yes | | | `standardInvoiceMissing` | array of string, nullable | yes | The fields a standard invoice still needs; null for a person. | | `createdAt` | string (date-time) | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **404** resource_not_found: no such id among what the key may read. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Update a customer `PATCH https://api.kosoor.com.sa/v1/customers/{id}` Needs customers:write. Changes only the fields the body carries. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Idempotency-Key` | string | yes | 1 to 255 printable ASCII characters, matched case-sensitively; a retry sends the same key. Kept 30 days. A key still in use answers 409 with Retry-After. Length 1–255. | | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | #### Request body `Content-Type: application/json` | Field | Type | Required | Description | |---|---|---|---| | `type` | string: `person`, `company`, nullable | | Fixed at create. | | `referenceId` | string, nullable | | Your own id for this customer, unique per tenant; set once. | | `firstName` | string, nullable | | | | `lastName` | string, nullable | | | | `phone` | string, nullable | | E.164, e.g. `+966501234567`. | | `email` | string, nullable | | | | `isActive` | boolean, nullable | | | | `branchId` | string (uuid), nullable | | | | `company` | object, nullable | | | | `company.name` | string, nullable | | | | `company.vatNumber` | string, nullable | | 15 digits that start and end with 3; unique per tenant. | | `company.idType` | string: `CRN`, `MOM`, `MLS`, `700`, `SAG`, `NAT`, `GCC`, `IQA`, `PAS`, `OTH`, nullable | | | | `company.idValue` | string, nullable | | | | `address` | object, nullable | | | | `address.street` | string, nullable | | | | `address.buildingNumber` | string, nullable | | | | `address.postalCode` | string, nullable | | | | `address.district` | string, nullable | | | | `address.city` | string, nullable | | | | `address.country` | string, nullable | | ISO 3166 alpha-2; required on a company. | | `metadata` | map of string, nullable | | Up to 20 keys of up to 40 characters, values up to 500. | #### Responses **200** OK (`application/json`) Header `Idempotent-Replayed`: true when this is the stored reply of an earlier request with the same key. | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | string | yes | Always `customer`: says what kind of object this is to code that receives several kinds. | | `type` | string: `person`, `company` | yes | | | `referenceId` | string, nullable | yes | | | `firstName` | string, nullable | yes | | | `lastName` | string, nullable | yes | | | `phone` | string, nullable | yes | | | `email` | string, nullable | yes | | | `isActive` | boolean | yes | | | `branchId` | string (uuid), nullable | yes | | | `company` | object, nullable | yes | | | `company.name` | string, nullable | yes | | | `company.vatNumber` | string, nullable | yes | | | `company.idType` | string, nullable | yes | | | `company.idValue` | string, nullable | yes | | | `company.creditLimit` | number (double) | yes | | | `company.creditPeriodDays` | integer (int32), nullable | yes | | | `address` | object | yes | | | `address.street` | string, nullable | yes | | | `address.buildingNumber` | string, nullable | yes | | | `address.postalCode` | string, nullable | yes | | | `address.district` | string, nullable | yes | | | `address.city` | string, nullable | yes | | | `address.country` | string, nullable | yes | | | `metadata` | map of string | yes | | | `standardInvoiceMissing` | array of string, nullable | yes | The fields a standard invoice still needs; null for a person. | | `createdAt` | string (date-time) | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **404** resource_not_found: no such id among what the key may read. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **409** A conflict: error.code says which. idempotency_in_progress comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **422** Refused by a business rule or a reused Idempotency-Key: error.code says which. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Issue a sales invoice `POST https://api.kosoor.com.sa/v1/sales-invoices` Needs sales_invoices:write. Creates and issues the invoice in one call. A company customer's standard invoice is cleared by ZATCA before the reply: rejected saves nothing, a timeout replies with zatca.status pending. A discount that would sell a unit below the item's minimum price is reduced to that price: read the applied unitDiscount and total from the reply. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Idempotency-Key` | string | yes | 1 to 255 printable ASCII characters, matched case-sensitively; a retry sends the same key. Kept 30 days. A key still in use answers 409 with Retry-After. Length 1–255. | | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Request body `Content-Type: application/json` | Field | Type | Required | Description | |---|---|---|---| | `customerId` | string (uuid) | yes | There is no walk-in sale. A company gets a standard invoice, cleared by ZATCA before the reply; a person gets a simplified one. | | `branchId` | string (uuid) | yes | | | `pricesIncludeVat` | boolean | yes | True when every unitPrice and unitDiscount includes VAT; such prices are converted and kept to the halala before VAT. | | `lines` | array of object | yes | 1 to 500 lines, each item at most once. | | `lines[].itemId` | string (uuid) | yes | | | `lines[].quantity` | integer | yes | 1 or more. | | `lines[].unitPrice` | number (double), nullable | | On the basis pricesIncludeVat states, kept to the halala; null takes the catalogue price, which an item priced per car size lacks. | | `lines[].unitDiscount` | number (double), nullable | | An amount off each unit, on the same basis as unitPrice; reduced so no unit sells below the item's minimum price. | | `lines[].description` | string, nullable | | Printed under the item's name. | | `payment` | object, nullable | | Money taken at issue; without it the whole invoice goes on the customer's account, which only a company may carry. | | `payment.paymentMethodId` | string (uuid) | yes | | | `payment.amount` | number (double), nullable | | At most two decimals. Null pays the whole invoice; less than the total leaves the rest on a company's account, and a person is refused. | | `payment.date` | string (date), nullable | | Null takes the invoice's date; never before it. | | `supplyDate` | string (date), nullable | | The day the goods or services were supplied, when not the issue day. | | `dueDate` | string (date), nullable | | | | `notes` | string, nullable | | Printed on the invoice. | | `costCentreId` | string (uuid), nullable | | | | `warehouseId` | string (uuid), nullable | | The warehouse the goods leave; null takes the main sales warehouse. | | `referenceId` | string, nullable | | Your own id for this invoice, up to 100 characters, unique across every invoice and credit note of the business. | | `metadata` | map of string, nullable | | Up to 20 keys of up to 40 characters, values up to 500. | #### Responses **201** Created (`application/json`) Header `Idempotent-Replayed`: true when this is the stored reply of an earlier request with the same key. | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | string | yes | Always `salesInvoice`: says what kind of object this is to code that receives several kinds. | | `number` | string, nullable | yes | As printed, e.g. «INV-1042». | | `issueDate` | string (date) | yes | | | `supplyDate` | string (date), nullable | yes | | | `dueDate` | string (date), nullable | yes | | | `branchId` | string (uuid) | yes | | | `customerId` | string (uuid), nullable | yes | | | `customer` | object, nullable | | Returned with `expand=customer`. | | `customer.id` | string (uuid) | yes | | | `customer.object` | string | yes | Always `customer`: says what kind of object this is to code that receives several kinds. | | `customer.type` | string: `person`, `company` | yes | | | `customer.referenceId` | string, nullable | yes | | | `customer.firstName` | string, nullable | yes | | | `customer.lastName` | string, nullable | yes | | | `customer.phone` | string, nullable | yes | | | `customer.email` | string, nullable | yes | | | `customer.isActive` | boolean | yes | | | `customer.branchId` | string (uuid), nullable | yes | | | `customer.company` | object, nullable | yes | | | `customer.company.name` | string, nullable | yes | | | `customer.company.vatNumber` | string, nullable | yes | | | `customer.company.idType` | string, nullable | yes | | | `customer.company.idValue` | string, nullable | yes | | | `customer.company.creditLimit` | number (double) | yes | | | `customer.company.creditPeriodDays` | integer (int32), nullable | yes | | | `customer.address` | object | yes | | | `customer.address.street` | string, nullable | yes | | | `customer.address.buildingNumber` | string, nullable | yes | | | `customer.address.postalCode` | string, nullable | yes | | | `customer.address.district` | string, nullable | yes | | | `customer.address.city` | string, nullable | yes | | | `customer.address.country` | string, nullable | yes | | | `customer.metadata` | map of string | yes | | | `customer.standardInvoiceMissing` | array of string, nullable | yes | The fields a standard invoice still needs; null for a person. | | `customer.createdAt` | string (date-time) | yes | | | `currency` | string | yes | | | `subtotal` | number (double) | yes | The lines before discount and VAT. | | `discountTotal` | number (double) | yes | | | `vatTotal` | number (double) | yes | | | `total` | number (double) | yes | | | `amountPaid` | number (double) | yes | | | `amountDue` | number (double) | yes | What the customer still owes on this invoice. | | `lines` | array of object, nullable | | Always on a single invoice; on a list only with `expand=lines`. | | `lines[].itemId` | string (uuid) | yes | | | `lines[].name` | string, nullable | yes | The item's name when it was sold. | | `lines[].description` | string, nullable | yes | | | `lines[].quantity` | number (double) | yes | | | `lines[].unitPrice` | number (double) | yes | Before VAT. | | `lines[].unitDiscount` | number (double) | yes | Per unit, before VAT, as applied after the item's minimum price. | | `lines[].vatRate` | number (double) | yes | A percentage, e.g. 15. | | `lines[].vatAmount` | number (double) | yes | | | `lines[].lineTotal` | number (double) | yes | After discount, with VAT. | | `notes` | string, nullable | yes | | | `costCentreId` | string (uuid), nullable | yes | | | `referenceId` | string, nullable | yes | | | `metadata` | map of string | yes | | | `createdVia` | string: `api`, `dashboard`, `pos`, `kiosk`, `storefront`, `unknown` | yes | `unknown` for an invoice saved before its channel was recorded. | | `zatca` | object | yes | Where the invoice stands with ZATCA; the document fields are null until it is signed. | | `zatca.kind` | string: `standard`, `simplified` | yes | `standard` is cleared before it reaches the buyer, `simplified` reported after. | | `zatca.status` | string: `pending`, `cleared`, `reported`, `rejected` | yes | | | `zatca.uuid` | string (uuid), nullable | yes | | | `zatca.counter` | integer (int32), nullable | yes | ZATCA's invoice counter value. | | `zatca.hash` | string, nullable | yes | | | `zatca.qr` | string, nullable | yes | Base64; for a standard invoice, the QR code ZATCA returned on clearance. | | `zatca.submittedAt` | string (date-time), nullable | yes | | | `zatca.clearedAt` | string (date-time), nullable | yes | | | `zatca.reportedAt` | string (date-time), nullable | yes | | | `zatca.errors` | array of object | yes | ZATCA's messages when it rejected the invoice. | | `zatca.errors[].code` | string | | | | `zatca.errors[].category` | string | | | | `zatca.errors[].message` | string | | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **409** A conflict: error.code says which. idempotency_in_progress comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **422** Refused by a business rule or a reused Idempotency-Key: error.code says which. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### List sales invoices `GET https://api.kosoor.com.sa/v1/sales-invoices` Needs sales_invoices:read. Every issued sales invoice in the key's branches, whichever channel created it. Newest first, paged with limit and startingAfter. expand=customer also needs customers:read. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `referenceId` | string | | | | `customerId` | string (uuid) | | | | `branchId` | string (uuid) | | | | `issuedAfter` | string (date-time) | | Issued at or after this instant, ISO 8601. | | `issuedBefore` | string (date-time) | | Issued before this instant, ISO 8601. | | `zatcaStatus` | string: `pending`, `cleared`, `reported`, `rejected` | | | | `expand` | array of string | | `lines` and `customer`; repeat the parameter for both. | | `limit` | integer (int32) | | 1 to 100; 20 when omitted. | | `startingAfter` | string (uuid) | | The id of the last row of the previous page. | #### Responses **200** OK (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `data` | array of object | yes | | | `data[].id` | string (uuid) | yes | | | `data[].object` | string | yes | Always `salesInvoice`: says what kind of object this is to code that receives several kinds. | | `data[].number` | string, nullable | yes | As printed, e.g. «INV-1042». | | `data[].issueDate` | string (date) | yes | | | `data[].supplyDate` | string (date), nullable | yes | | | `data[].dueDate` | string (date), nullable | yes | | | `data[].branchId` | string (uuid) | yes | | | `data[].customerId` | string (uuid), nullable | yes | | | `data[].customer` | object, nullable | | Returned with `expand=customer`. | | `data[].customer.id` | string (uuid) | yes | | | `data[].customer.object` | string | yes | Always `customer`: says what kind of object this is to code that receives several kinds. | | `data[].customer.type` | string: `person`, `company` | yes | | | `data[].customer.referenceId` | string, nullable | yes | | | `data[].customer.firstName` | string, nullable | yes | | | `data[].customer.lastName` | string, nullable | yes | | | `data[].customer.phone` | string, nullable | yes | | | `data[].customer.email` | string, nullable | yes | | | `data[].customer.isActive` | boolean | yes | | | `data[].customer.branchId` | string (uuid), nullable | yes | | | `data[].customer.company` | object, nullable | yes | | | `data[].customer.company.name` | string, nullable | yes | | | `data[].customer.company.vatNumber` | string, nullable | yes | | | `data[].customer.company.idType` | string, nullable | yes | | | `data[].customer.company.idValue` | string, nullable | yes | | | `data[].customer.company.creditLimit` | number (double) | yes | | | `data[].customer.company.creditPeriodDays` | integer (int32), nullable | yes | | | `data[].customer.address` | object | yes | | | `data[].customer.address.street` | string, nullable | yes | | | `data[].customer.address.buildingNumber` | string, nullable | yes | | | `data[].customer.address.postalCode` | string, nullable | yes | | | `data[].customer.address.district` | string, nullable | yes | | | `data[].customer.address.city` | string, nullable | yes | | | `data[].customer.address.country` | string, nullable | yes | | | `data[].customer.metadata` | map of string | yes | | | `data[].customer.standardInvoiceMissing` | array of string, nullable | yes | The fields a standard invoice still needs; null for a person. | | `data[].customer.createdAt` | string (date-time) | yes | | | `data[].currency` | string | yes | | | `data[].subtotal` | number (double) | yes | The lines before discount and VAT. | | `data[].discountTotal` | number (double) | yes | | | `data[].vatTotal` | number (double) | yes | | | `data[].total` | number (double) | yes | | | `data[].amountPaid` | number (double) | yes | | | `data[].amountDue` | number (double) | yes | What the customer still owes on this invoice. | | `data[].lines` | array of object, nullable | | Always on a single invoice; on a list only with `expand=lines`. | | `data[].lines[].itemId` | string (uuid) | yes | | | `data[].lines[].name` | string, nullable | yes | The item's name when it was sold. | | `data[].lines[].description` | string, nullable | yes | | | `data[].lines[].quantity` | number (double) | yes | | | `data[].lines[].unitPrice` | number (double) | yes | Before VAT. | | `data[].lines[].unitDiscount` | number (double) | yes | Per unit, before VAT, as applied after the item's minimum price. | | `data[].lines[].vatRate` | number (double) | yes | A percentage, e.g. 15. | | `data[].lines[].vatAmount` | number (double) | yes | | | `data[].lines[].lineTotal` | number (double) | yes | After discount, with VAT. | | `data[].notes` | string, nullable | yes | | | `data[].costCentreId` | string (uuid), nullable | yes | | | `data[].referenceId` | string, nullable | yes | | | `data[].metadata` | map of string | yes | | | `data[].createdVia` | string: `api`, `dashboard`, `pos`, `kiosk`, `storefront`, `unknown` | yes | `unknown` for an invoice saved before its channel was recorded. | | `data[].zatca` | object | yes | Where the invoice stands with ZATCA; the document fields are null until it is signed. | | `data[].zatca.kind` | string: `standard`, `simplified` | yes | `standard` is cleared before it reaches the buyer, `simplified` reported after. | | `data[].zatca.status` | string: `pending`, `cleared`, `reported`, `rejected` | yes | | | `data[].zatca.uuid` | string (uuid), nullable | yes | | | `data[].zatca.counter` | integer (int32), nullable | yes | ZATCA's invoice counter value. | | `data[].zatca.hash` | string, nullable | yes | | | `data[].zatca.qr` | string, nullable | yes | Base64; for a standard invoice, the QR code ZATCA returned on clearance. | | `data[].zatca.submittedAt` | string (date-time), nullable | yes | | | `data[].zatca.clearedAt` | string (date-time), nullable | yes | | | `data[].zatca.reportedAt` | string (date-time), nullable | yes | | | `data[].zatca.errors` | array of object | yes | ZATCA's messages when it rejected the invoice. | | `data[].zatca.errors[].code` | string | | | | `data[].zatca.errors[].category` | string | | | | `data[].zatca.errors[].message` | string | | | | `hasMore` | boolean | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Retrieve a sales invoice `GET https://api.kosoor.com.sa/v1/sales-invoices/{id}` Needs sales_invoices:read. Always includes the lines; expand=customer adds the customer and also needs customers:read. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | #### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `expand` | array of string | | `customer`; the lines are always included. | #### Responses **200** OK (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | string | yes | Always `salesInvoice`: says what kind of object this is to code that receives several kinds. | | `number` | string, nullable | yes | As printed, e.g. «INV-1042». | | `issueDate` | string (date) | yes | | | `supplyDate` | string (date), nullable | yes | | | `dueDate` | string (date), nullable | yes | | | `branchId` | string (uuid) | yes | | | `customerId` | string (uuid), nullable | yes | | | `customer` | object, nullable | | Returned with `expand=customer`. | | `customer.id` | string (uuid) | yes | | | `customer.object` | string | yes | Always `customer`: says what kind of object this is to code that receives several kinds. | | `customer.type` | string: `person`, `company` | yes | | | `customer.referenceId` | string, nullable | yes | | | `customer.firstName` | string, nullable | yes | | | `customer.lastName` | string, nullable | yes | | | `customer.phone` | string, nullable | yes | | | `customer.email` | string, nullable | yes | | | `customer.isActive` | boolean | yes | | | `customer.branchId` | string (uuid), nullable | yes | | | `customer.company` | object, nullable | yes | | | `customer.company.name` | string, nullable | yes | | | `customer.company.vatNumber` | string, nullable | yes | | | `customer.company.idType` | string, nullable | yes | | | `customer.company.idValue` | string, nullable | yes | | | `customer.company.creditLimit` | number (double) | yes | | | `customer.company.creditPeriodDays` | integer (int32), nullable | yes | | | `customer.address` | object | yes | | | `customer.address.street` | string, nullable | yes | | | `customer.address.buildingNumber` | string, nullable | yes | | | `customer.address.postalCode` | string, nullable | yes | | | `customer.address.district` | string, nullable | yes | | | `customer.address.city` | string, nullable | yes | | | `customer.address.country` | string, nullable | yes | | | `customer.metadata` | map of string | yes | | | `customer.standardInvoiceMissing` | array of string, nullable | yes | The fields a standard invoice still needs; null for a person. | | `customer.createdAt` | string (date-time) | yes | | | `currency` | string | yes | | | `subtotal` | number (double) | yes | The lines before discount and VAT. | | `discountTotal` | number (double) | yes | | | `vatTotal` | number (double) | yes | | | `total` | number (double) | yes | | | `amountPaid` | number (double) | yes | | | `amountDue` | number (double) | yes | What the customer still owes on this invoice. | | `lines` | array of object, nullable | | Always on a single invoice; on a list only with `expand=lines`. | | `lines[].itemId` | string (uuid) | yes | | | `lines[].name` | string, nullable | yes | The item's name when it was sold. | | `lines[].description` | string, nullable | yes | | | `lines[].quantity` | number (double) | yes | | | `lines[].unitPrice` | number (double) | yes | Before VAT. | | `lines[].unitDiscount` | number (double) | yes | Per unit, before VAT, as applied after the item's minimum price. | | `lines[].vatRate` | number (double) | yes | A percentage, e.g. 15. | | `lines[].vatAmount` | number (double) | yes | | | `lines[].lineTotal` | number (double) | yes | After discount, with VAT. | | `notes` | string, nullable | yes | | | `costCentreId` | string (uuid), nullable | yes | | | `referenceId` | string, nullable | yes | | | `metadata` | map of string | yes | | | `createdVia` | string: `api`, `dashboard`, `pos`, `kiosk`, `storefront`, `unknown` | yes | `unknown` for an invoice saved before its channel was recorded. | | `zatca` | object | yes | Where the invoice stands with ZATCA; the document fields are null until it is signed. | | `zatca.kind` | string: `standard`, `simplified` | yes | `standard` is cleared before it reaches the buyer, `simplified` reported after. | | `zatca.status` | string: `pending`, `cleared`, `reported`, `rejected` | yes | | | `zatca.uuid` | string (uuid), nullable | yes | | | `zatca.counter` | integer (int32), nullable | yes | ZATCA's invoice counter value. | | `zatca.hash` | string, nullable | yes | | | `zatca.qr` | string, nullable | yes | Base64; for a standard invoice, the QR code ZATCA returned on clearance. | | `zatca.submittedAt` | string (date-time), nullable | yes | | | `zatca.clearedAt` | string (date-time), nullable | yes | | | `zatca.reportedAt` | string (date-time), nullable | yes | | | `zatca.errors` | array of object | yes | ZATCA's messages when it rejected the invoice. | | `zatca.errors[].code` | string | | | | `zatca.errors[].category` | string | | | | `zatca.errors[].message` | string | | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **404** resource_not_found: no such id among what the key may read. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Download a sales invoice's PDF `GET https://api.kosoor.com.sa/v1/sales-invoices/{id}/pdf` Needs sales_invoices:read. The invoice on A4, standard or simplified. A standard invoice answers 409 invoice_not_cleared until ZATCA clears it. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | #### Responses **200** OK (`application/pdf`) **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **404** Not Found (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **409** Conflict (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Download a sales invoice's ZATCA XML `GET https://api.kosoor.com.sa/v1/sales-invoices/{id}/xml` Needs sales_invoices:read. The XML ZATCA cleared (standard) or the signed XML reported to it (simplified); 409 invoice_not_cleared until ZATCA accepts it, and for good once ZATCA rejects it. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | #### Responses **200** OK (`application/xml`) **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **404** Not Found (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **409** Conflict (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Record a payment `POST https://api.kosoor.com.sa/v1/payments` Needs payments:write. Records and issues the payment in one call; an issued payment is never cancelled, and money going back is a credit note. Allocations settle the customer's invoices; what is left stays as their credit. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Idempotency-Key` | string | yes | 1 to 255 printable ASCII characters, matched case-sensitively; a retry sends the same key. Kept 30 days. A key still in use answers 409 with Retry-After. Length 1–255. | | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Request body `Content-Type: application/json` | Field | Type | Required | Description | |---|---|---|---| | `customerId` | string (uuid) | yes | A person or a company; there is no walk-in receipt. | | `branchId` | string (uuid) | yes | The branch the money was received in; the payment method's treasury must serve it. | | `paymentMethodId` | string (uuid) | yes | | | `amount` | number (double) | yes | Above 0, at most two decimals. | | `date` | string (date), nullable | | Null takes today in Saudi time; a past date is allowed unless its accounting period is closed or it is before an allocated invoice. | | `allocations` | array of object, nullable | | Up to 100 invoices this payment settles, each at most once; what is left stays as the customer's credit. | | `allocations[].invoiceId` | string (uuid) | yes | One of the customer's issued sales invoices, issued in a branch the key may use and not after the payment's date. | | `allocations[].amount` | number (double) | yes | Above 0, at most two decimals, and no more than the invoice still owes. | | `notes` | string, nullable | | Up to 500 characters. | | `referenceId` | string, nullable | | Your own id for this payment, up to 100 characters, unique across every payment of the business. | | `metadata` | map of string, nullable | | Up to 20 keys of up to 40 characters, values up to 500. | #### Responses **201** Created (`application/json`) Header `Idempotent-Replayed`: true when this is the stored reply of an earlier request with the same key. | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | string | yes | Always `payment`: says what kind of object this is to code that receives several kinds. | | `number` | string | yes | As printed on the receipt. | | `date` | string (date) | yes | | | `customerId` | string (uuid) | yes | | | `branchId` | string (uuid), nullable | yes | Null on a receipt recorded with no branch; a key limited to branches never sees one. | | `paymentMethodId` | string (uuid), nullable | yes | Null on a receipt recorded with no payment method. | | `amount` | number (double) | yes | | | `discount` | number (double) | yes | Debt forgiven on top of the money, a settlement discount given on the dashboard; 0 for a payment made through the API. | | `allocations` | array of object | yes | | | `allocations[].invoiceId` | string (uuid) | yes | | | `allocations[].amount` | number (double) | yes | | | `unallocatedAmount` | number (double) | yes | amount plus discount, less the allocations: the customer's credit. | | `notes` | string, nullable | yes | | | `referenceId` | string, nullable | yes | | | `metadata` | map of string | yes | | | `createdVia` | string: `api`, `dashboard`, `pos`, `kiosk`, `storefront`, `unknown` | yes | `unknown` for a receipt saved before its channel was recorded. | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **409** A conflict: error.code says which. idempotency_in_progress comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **422** Refused by a business rule or a reused Idempotency-Key: error.code says which. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### List payments `GET https://api.kosoor.com.sa/v1/payments` Needs payments:read. Every issued payment in the key's branches, including one taken with a sale to a company customer. Newest first, paged with limit and startingAfter. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `referenceId` | string | | | | `customerId` | string (uuid) | | | | `branchId` | string (uuid) | | | | `invoiceId` | string (uuid) | | Payments with an allocation to this invoice. | | `dateFrom` | string (date) | | Payments dated on or after this day. | | `dateTo` | string (date) | | Payments dated on or before this day. | | `limit` | integer (int32) | | 1 to 100; 20 when omitted. | | `startingAfter` | string (uuid) | | The id of the last row of the previous page. | #### Responses **200** OK (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `data` | array of object | yes | | | `data[].id` | string (uuid) | yes | | | `data[].object` | string | yes | Always `payment`: says what kind of object this is to code that receives several kinds. | | `data[].number` | string | yes | As printed on the receipt. | | `data[].date` | string (date) | yes | | | `data[].customerId` | string (uuid) | yes | | | `data[].branchId` | string (uuid), nullable | yes | Null on a receipt recorded with no branch; a key limited to branches never sees one. | | `data[].paymentMethodId` | string (uuid), nullable | yes | Null on a receipt recorded with no payment method. | | `data[].amount` | number (double) | yes | | | `data[].discount` | number (double) | yes | Debt forgiven on top of the money, a settlement discount given on the dashboard; 0 for a payment made through the API. | | `data[].allocations` | array of object | yes | | | `data[].allocations[].invoiceId` | string (uuid) | yes | | | `data[].allocations[].amount` | number (double) | yes | | | `data[].unallocatedAmount` | number (double) | yes | amount plus discount, less the allocations: the customer's credit. | | `data[].notes` | string, nullable | yes | | | `data[].referenceId` | string, nullable | yes | | | `data[].metadata` | map of string | yes | | | `data[].createdVia` | string: `api`, `dashboard`, `pos`, `kiosk`, `storefront`, `unknown` | yes | `unknown` for a receipt saved before its channel was recorded. | | `hasMore` | boolean | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Retrieve a payment `GET https://api.kosoor.com.sa/v1/payments/{id}` Needs payments:read. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | #### Responses **200** OK (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | string | yes | Always `payment`: says what kind of object this is to code that receives several kinds. | | `number` | string | yes | As printed on the receipt. | | `date` | string (date) | yes | | | `customerId` | string (uuid) | yes | | | `branchId` | string (uuid), nullable | yes | Null on a receipt recorded with no branch; a key limited to branches never sees one. | | `paymentMethodId` | string (uuid), nullable | yes | Null on a receipt recorded with no payment method. | | `amount` | number (double) | yes | | | `discount` | number (double) | yes | Debt forgiven on top of the money, a settlement discount given on the dashboard; 0 for a payment made through the API. | | `allocations` | array of object | yes | | | `allocations[].invoiceId` | string (uuid) | yes | | | `allocations[].amount` | number (double) | yes | | | `unallocatedAmount` | number (double) | yes | amount plus discount, less the allocations: the customer's credit. | | `notes` | string, nullable | yes | | | `referenceId` | string, nullable | yes | | | `metadata` | map of string | yes | | | `createdVia` | string: `api`, `dashboard`, `pos`, `kiosk`, `storefront`, `unknown` | yes | `unknown` for a receipt saved before its channel was recorded. | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **404** resource_not_found: no such id among what the key may read. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Issue a credit note `POST https://api.kosoor.com.sa/v1/credit-notes` Needs credit_notes:write. Credits items of an issued sales invoice and issues the credit note in one call; the goods go back to stock. With refund, the total is paid back through that payment method; without it, the total comes off what the customer owes, which needs a company customer, or an individual crediting no more than the invoice owes. A credit note for a standard invoice is cleared by ZATCA before the reply: rejected saves nothing, a timeout replies with zatca.status pending. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Idempotency-Key` | string | yes | 1 to 255 printable ASCII characters, matched case-sensitively; a retry sends the same key. Kept 30 days. A key still in use answers 409 with Retry-After. Length 1–255. | | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Request body `Content-Type: application/json` | Field | Type | Required | Description | |---|---|---|---| | `invoiceId` | string (uuid) | yes | The issued sales invoice to credit; the branch, the customer and the prices come from it. | | `reason` | string: `cancellation`, `refund`, `informationChange` | yes | Sent to ZATCA as the reason. | | `lines` | array of object | yes | 1 to 500 items of the invoice, each at most once; the goods go back to stock. | | `lines[].itemId` | string (uuid) | yes | An item sold on the invoice. | | `lines[].quantity` | integer | yes | 1 or more, and no more than is left of the item to credit. | | `refund` | object, nullable | | Pays the credit note's total back through a payment method; null takes it off what the customer owes, which a sale with no customer, or an individual crediting more than the invoice owes, cannot do. | | `refund.paymentMethodId` | string (uuid) | yes | The method the money goes back through, paid out of its treasury in the invoice's branch. | | `notes` | string, nullable | | Up to 500 characters, printed on the credit note. | | `referenceId` | string, nullable | | Your own id for this credit note, up to 100 characters, unique across every invoice and credit note of the business. | | `metadata` | map of string, nullable | | Up to 20 keys of up to 40 characters, values up to 500. | #### Responses **201** Created (`application/json`) Header `Idempotent-Replayed`: true when this is the stored reply of an earlier request with the same key. | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | string | yes | Always `creditNote`: says what kind of object this is to code that receives several kinds. | | `number` | string, nullable | yes | As printed on the credit note. | | `invoiceId` | string (uuid) | yes | The sales invoice it credits. | | `customerId` | string (uuid), nullable | yes | | | `branchId` | string (uuid) | yes | | | `issueDate` | string (date) | yes | | | `reason` | string: `cancellation`, `refund`, `informationChange` | yes | | | `currency` | string | yes | | | `lines` | array of object | yes | | | `lines[].itemId` | string (uuid) | yes | | | `lines[].name` | string, nullable | yes | The item's name when it was sold. | | `lines[].description` | string, nullable | yes | | | `lines[].quantity` | number (double) | yes | | | `lines[].unitPrice` | number (double) | yes | Before VAT. | | `lines[].unitDiscount` | number (double) | yes | Per unit, before VAT, as applied after the item's minimum price. | | `lines[].vatRate` | number (double) | yes | A percentage, e.g. 15. | | `lines[].vatAmount` | number (double) | yes | | | `lines[].lineTotal` | number (double) | yes | After discount, with VAT. | | `subtotal` | number (double) | yes | | | `discountTotal` | number (double) | yes | | | `vatTotal` | number (double) | yes | | | `total` | number (double) | yes | | | `refund` | object, nullable | yes | The money paid back; null when the credit came off what the customer owes. | | `refund.paymentMethodId` | string (uuid), nullable | yes | Null when the refund named no method, as POS refunds usually do: the money went back through the branch's own POS, or to the card the sale took. | | `refund.amount` | number (double) | yes | | | `notes` | string, nullable | yes | | | `referenceId` | string, nullable | yes | | | `metadata` | map of string | yes | | | `createdVia` | string: `api`, `dashboard`, `pos`, `kiosk`, `storefront`, `unknown` | yes | `unknown` for one saved before its channel was recorded. | | `zatca` | object | yes | Where the credit note stands with ZATCA; the document fields are null until it is signed. | | `zatca.kind` | string: `standard`, `simplified` | yes | `standard` is cleared before it reaches the buyer, `simplified` reported after. | | `zatca.status` | string: `pending`, `cleared`, `reported`, `rejected` | yes | | | `zatca.uuid` | string (uuid), nullable | yes | | | `zatca.counter` | integer (int32), nullable | yes | ZATCA's invoice counter value. | | `zatca.hash` | string, nullable | yes | | | `zatca.qr` | string, nullable | yes | Base64; for a standard invoice, the QR code ZATCA returned on clearance. | | `zatca.submittedAt` | string (date-time), nullable | yes | | | `zatca.clearedAt` | string (date-time), nullable | yes | | | `zatca.reportedAt` | string (date-time), nullable | yes | | | `zatca.errors` | array of object | yes | ZATCA's messages when it rejected the invoice. | | `zatca.errors[].code` | string | | | | `zatca.errors[].category` | string | | | | `zatca.errors[].message` | string | | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **409** A conflict: error.code says which. idempotency_in_progress comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **422** Refused by a business rule or a reused Idempotency-Key: error.code says which. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### List credit notes `GET https://api.kosoor.com.sa/v1/credit-notes` Needs credit_notes:read. Every issued credit note in the key's branches, whichever channel created it, including refunds made at the POS or the dashboard. Sales returns and credit notes made on the dashboard's accounting screens are not listed, though they count against what is left to credit. Newest first, paged with limit and startingAfter. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `invoiceId` | string (uuid) | | Credit notes against this sales invoice. | | `referenceId` | string | | | | `customerId` | string (uuid) | | | | `branchId` | string (uuid) | | | | `issuedAfter` | string (date-time) | | Issued at or after this instant, ISO 8601. | | `issuedBefore` | string (date-time) | | Issued before this instant, ISO 8601. | | `zatcaStatus` | string: `pending`, `cleared`, `reported`, `rejected` | | | | `limit` | integer (int32) | | 1 to 100; 20 when omitted. | | `startingAfter` | string (uuid) | | The id of the last row of the previous page. | #### Responses **200** OK (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `data` | array of object | yes | | | `data[].id` | string (uuid) | yes | | | `data[].object` | string | yes | Always `creditNote`: says what kind of object this is to code that receives several kinds. | | `data[].number` | string, nullable | yes | As printed on the credit note. | | `data[].invoiceId` | string (uuid) | yes | The sales invoice it credits. | | `data[].customerId` | string (uuid), nullable | yes | | | `data[].branchId` | string (uuid) | yes | | | `data[].issueDate` | string (date) | yes | | | `data[].reason` | string: `cancellation`, `refund`, `informationChange` | yes | | | `data[].currency` | string | yes | | | `data[].lines` | array of object | yes | | | `data[].lines[].itemId` | string (uuid) | yes | | | `data[].lines[].name` | string, nullable | yes | The item's name when it was sold. | | `data[].lines[].description` | string, nullable | yes | | | `data[].lines[].quantity` | number (double) | yes | | | `data[].lines[].unitPrice` | number (double) | yes | Before VAT. | | `data[].lines[].unitDiscount` | number (double) | yes | Per unit, before VAT, as applied after the item's minimum price. | | `data[].lines[].vatRate` | number (double) | yes | A percentage, e.g. 15. | | `data[].lines[].vatAmount` | number (double) | yes | | | `data[].lines[].lineTotal` | number (double) | yes | After discount, with VAT. | | `data[].subtotal` | number (double) | yes | | | `data[].discountTotal` | number (double) | yes | | | `data[].vatTotal` | number (double) | yes | | | `data[].total` | number (double) | yes | | | `data[].refund` | object, nullable | yes | The money paid back; null when the credit came off what the customer owes. | | `data[].refund.paymentMethodId` | string (uuid), nullable | yes | Null when the refund named no method, as POS refunds usually do: the money went back through the branch's own POS, or to the card the sale took. | | `data[].refund.amount` | number (double) | yes | | | `data[].notes` | string, nullable | yes | | | `data[].referenceId` | string, nullable | yes | | | `data[].metadata` | map of string | yes | | | `data[].createdVia` | string: `api`, `dashboard`, `pos`, `kiosk`, `storefront`, `unknown` | yes | `unknown` for one saved before its channel was recorded. | | `data[].zatca` | object | yes | Where the credit note stands with ZATCA; the document fields are null until it is signed. | | `data[].zatca.kind` | string: `standard`, `simplified` | yes | `standard` is cleared before it reaches the buyer, `simplified` reported after. | | `data[].zatca.status` | string: `pending`, `cleared`, `reported`, `rejected` | yes | | | `data[].zatca.uuid` | string (uuid), nullable | yes | | | `data[].zatca.counter` | integer (int32), nullable | yes | ZATCA's invoice counter value. | | `data[].zatca.hash` | string, nullable | yes | | | `data[].zatca.qr` | string, nullable | yes | Base64; for a standard invoice, the QR code ZATCA returned on clearance. | | `data[].zatca.submittedAt` | string (date-time), nullable | yes | | | `data[].zatca.clearedAt` | string (date-time), nullable | yes | | | `data[].zatca.reportedAt` | string (date-time), nullable | yes | | | `data[].zatca.errors` | array of object | yes | ZATCA's messages when it rejected the invoice. | | `data[].zatca.errors[].code` | string | | | | `data[].zatca.errors[].category` | string | | | | `data[].zatca.errors[].message` | string | | | | `hasMore` | boolean | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Retrieve a credit note `GET https://api.kosoor.com.sa/v1/credit-notes/{id}` Needs credit_notes:read. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | #### Responses **200** OK (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | string | yes | Always `creditNote`: says what kind of object this is to code that receives several kinds. | | `number` | string, nullable | yes | As printed on the credit note. | | `invoiceId` | string (uuid) | yes | The sales invoice it credits. | | `customerId` | string (uuid), nullable | yes | | | `branchId` | string (uuid) | yes | | | `issueDate` | string (date) | yes | | | `reason` | string: `cancellation`, `refund`, `informationChange` | yes | | | `currency` | string | yes | | | `lines` | array of object | yes | | | `lines[].itemId` | string (uuid) | yes | | | `lines[].name` | string, nullable | yes | The item's name when it was sold. | | `lines[].description` | string, nullable | yes | | | `lines[].quantity` | number (double) | yes | | | `lines[].unitPrice` | number (double) | yes | Before VAT. | | `lines[].unitDiscount` | number (double) | yes | Per unit, before VAT, as applied after the item's minimum price. | | `lines[].vatRate` | number (double) | yes | A percentage, e.g. 15. | | `lines[].vatAmount` | number (double) | yes | | | `lines[].lineTotal` | number (double) | yes | After discount, with VAT. | | `subtotal` | number (double) | yes | | | `discountTotal` | number (double) | yes | | | `vatTotal` | number (double) | yes | | | `total` | number (double) | yes | | | `refund` | object, nullable | yes | The money paid back; null when the credit came off what the customer owes. | | `refund.paymentMethodId` | string (uuid), nullable | yes | Null when the refund named no method, as POS refunds usually do: the money went back through the branch's own POS, or to the card the sale took. | | `refund.amount` | number (double) | yes | | | `notes` | string, nullable | yes | | | `referenceId` | string, nullable | yes | | | `metadata` | map of string | yes | | | `createdVia` | string: `api`, `dashboard`, `pos`, `kiosk`, `storefront`, `unknown` | yes | `unknown` for one saved before its channel was recorded. | | `zatca` | object | yes | Where the credit note stands with ZATCA; the document fields are null until it is signed. | | `zatca.kind` | string: `standard`, `simplified` | yes | `standard` is cleared before it reaches the buyer, `simplified` reported after. | | `zatca.status` | string: `pending`, `cleared`, `reported`, `rejected` | yes | | | `zatca.uuid` | string (uuid), nullable | yes | | | `zatca.counter` | integer (int32), nullable | yes | ZATCA's invoice counter value. | | `zatca.hash` | string, nullable | yes | | | `zatca.qr` | string, nullable | yes | Base64; for a standard invoice, the QR code ZATCA returned on clearance. | | `zatca.submittedAt` | string (date-time), nullable | yes | | | `zatca.clearedAt` | string (date-time), nullable | yes | | | `zatca.reportedAt` | string (date-time), nullable | yes | | | `zatca.errors` | array of object | yes | ZATCA's messages when it rejected the invoice. | | `zatca.errors[].code` | string | | | | `zatca.errors[].category` | string | | | | `zatca.errors[].message` | string | | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **404** resource_not_found: no such id among what the key may read. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Download a credit note's PDF `GET https://api.kosoor.com.sa/v1/credit-notes/{id}/pdf` Needs credit_notes:read. The credit note on A4. One for a standard invoice answers 409 invoice_not_cleared until ZATCA clears it. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | #### Responses **200** OK (`application/pdf`) **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **404** Not Found (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **409** Conflict (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Download a credit note's ZATCA XML `GET https://api.kosoor.com.sa/v1/credit-notes/{id}/xml` Needs credit_notes:read. The XML ZATCA cleared (standard) or the signed XML reported to it (simplified); 409 invoice_not_cleared until ZATCA accepts it, and for good once ZATCA rejects it. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | #### Responses **200** OK (`application/xml`) **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **404** Not Found (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **409** Conflict (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Retrieve a branch `GET https://api.kosoor.com.sa/v1/branches/{id}` Needs branches:read. A branch outside the key's branches is not found. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | #### Responses **200** OK (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | string | yes | Always `branch`. | | `number` | integer (int32), nullable | yes | The merchant's own branch number, as its receipts print it. | | `name` | string | yes | | | `isActive` | boolean | yes | | | `createdAt` | string (date-time) | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **404** resource_not_found: no such id among what the key may read. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### List branches `GET https://api.kosoor.com.sa/v1/branches` Needs branches:read. The branches the key may use, newest first, paged with limit and startingAfter. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `limit` | integer (int32) | | 1 to 100; 20 when omitted. | | `startingAfter` | string (uuid) | | The id of the last row of the previous page. | #### Responses **200** OK (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `data` | array of object | yes | | | `data[].id` | string (uuid) | yes | | | `data[].object` | string | yes | Always `branch`. | | `data[].number` | integer (int32), nullable | yes | The merchant's own branch number, as its receipts print it. | | `data[].name` | string | yes | | | `data[].isActive` | boolean | yes | | | `data[].createdAt` | string (date-time) | yes | | | `hasMore` | boolean | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Retrieve an item `GET https://api.kosoor.com.sa/v1/items/{id}` Needs items:read. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | #### Responses **200** OK (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | string | yes | Always `item`. | | `code` | string | yes | The merchant's item code. | | `barcode` | string, nullable | yes | | | `name` | string | yes | | | `kind` | string: `service`, `product`, `subscription` | yes | | | `sellable` | boolean | yes | Whether a sales invoice line may name it; a subscription never can. | | `price` | number (double) | yes | The list price, before or including VAT as Kosoor.MerchantApi.Items.ItemReply.PriceIncludesVat says. | | `priceIncludesVat` | boolean | yes | | | `vatRate` | number (double) | yes | A percentage: 15 means 15%. | | `unitPriceRequired` | boolean | yes | Priced per car size, so every invoice line naming it sends `unitPrice`. | | `minimumPrice` | number (double) | yes | The lowest unit price a discount can bring it to, including VAT; 0 for none. | | `isActive` | boolean | yes | | | `createdAt` | string (date-time) | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **404** resource_not_found: no such id among what the key may read. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### List items `GET https://api.kosoor.com.sa/v1/items` Needs items:read. Newest first, paged with limit and startingAfter. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `kind` | string: `service`, `product`, `subscription` | | | | `code` | string | | | | `barcode` | string | | | | `limit` | integer (int32) | | 1 to 100; 20 when omitted. | | `startingAfter` | string (uuid) | | The id of the last row of the previous page. | #### Responses **200** OK (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `data` | array of object | yes | | | `data[].id` | string (uuid) | yes | | | `data[].object` | string | yes | Always `item`. | | `data[].code` | string | yes | The merchant's item code. | | `data[].barcode` | string, nullable | yes | | | `data[].name` | string | yes | | | `data[].kind` | string: `service`, `product`, `subscription` | yes | | | `data[].sellable` | boolean | yes | Whether a sales invoice line may name it; a subscription never can. | | `data[].price` | number (double) | yes | The list price, before or including VAT as Kosoor.MerchantApi.Items.ItemReply.PriceIncludesVat says. | | `data[].priceIncludesVat` | boolean | yes | | | `data[].vatRate` | number (double) | yes | A percentage: 15 means 15%. | | `data[].unitPriceRequired` | boolean | yes | Priced per car size, so every invoice line naming it sends `unitPrice`. | | `data[].minimumPrice` | number (double) | yes | The lowest unit price a discount can bring it to, including VAT; 0 for none. | | `data[].isActive` | boolean | yes | | | `data[].createdAt` | string (date-time) | yes | | | `hasMore` | boolean | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Retrieve a payment method `GET https://api.kosoor.com.sa/v1/payment-methods/{id}` Needs payment_methods:read. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | #### Responses **200** OK (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | string | yes | Always `paymentMethod`. | | `code` | string | yes | | | `name` | string | yes | The merchant's name for it, usually Arabic. | | `nameEn` | string, nullable | yes | | | `kind` | string: `cash`, `card`, `bank_transfer`, `cheque`, `other` | yes | | | `isActive` | boolean | yes | | | `createdAt` | string (date-time) | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **404** resource_not_found: no such id among what the key may read. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### List payment methods `GET https://api.kosoor.com.sa/v1/payment-methods` Needs payment_methods:read. Newest first, paged with limit and startingAfter. Whether a method can take a payment at a branch depends on the treasury set for that branch: see payment_method_not_in_branch. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `limit` | integer (int32) | | 1 to 100; 20 when omitted. | | `startingAfter` | string (uuid) | | The id of the last row of the previous page. | #### Responses **200** OK (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `data` | array of object | yes | | | `data[].id` | string (uuid) | yes | | | `data[].object` | string | yes | Always `paymentMethod`. | | `data[].code` | string | yes | | | `data[].name` | string | yes | The merchant's name for it, usually Arabic. | | `data[].nameEn` | string, nullable | yes | | | `data[].kind` | string: `cash`, `card`, `bank_transfer`, `cheque`, `other` | yes | | | `data[].isActive` | boolean | yes | | | `data[].createdAt` | string (date-time) | yes | | | `hasMore` | boolean | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Create a webhook endpoint `POST https://api.kosoor.com.sa/v1/webhook-endpoints` Needs webhooks:write. The reply carries the signing secret, shown only this once. A test key's endpoints receive sandbox events only. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Idempotency-Key` | string | yes | 1 to 255 printable ASCII characters, matched case-sensitively; a retry sends the same key. Kept 30 days. A key still in use answers 409 with Retry-After. Length 1–255. | | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Request body `Content-Type: application/json` | Field | Type | Required | Description | |---|---|---|---| | `url` | string | yes | An `https` URL of up to 2048 characters whose host resolves to a public address. | | `events` | array of string: `salesInvoice.zatcaCleared`, `salesInvoice.zatcaReported`, `salesInvoice.zatcaRejected`, `creditNote.zatcaCleared`, `creditNote.zatcaReported`, `creditNote.zatcaRejected` | yes | One or more event types, each at most once. | #### Responses **201** Created (`application/json`) Header `Idempotent-Replayed`: true when this is the stored reply of an earlier request with the same key. | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | string | yes | | | `url` | string | yes | | | `events` | array of string | yes | | | `status` | string: `enabled`, `disabled` | yes | | | `disabledReason` | string: `merchant`, `gone`, `failing`, nullable | yes | Why a disabled endpoint stopped: `merchant` switched it off, the receiver answered `gone` (410), or it was `failing` for five days. | | `secret` | string, nullable | | Only in the reply to create and rotate-secret: store it, it is never shown again. | | `createdAt` | string (date-time) | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **409** A conflict: error.code says which. idempotency_in_progress comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **422** Refused by a business rule or a reused Idempotency-Key: error.code says which. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### List webhook endpoints `GET https://api.kosoor.com.sa/v1/webhook-endpoints` Needs webhooks:read. The key's own endpoints, newest first, paged with limit and startingAfter. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Query parameters | Name | Type | Required | Description | |---|---|---|---| | `limit` | integer (int32) | | 1 to 100; 20 when omitted. | | `startingAfter` | string (uuid) | | The id of the last row of the previous page. | #### Responses **200** OK (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `data` | array of object | yes | | | `data[].id` | string (uuid) | yes | | | `data[].object` | string | yes | | | `data[].url` | string | yes | | | `data[].events` | array of string | yes | | | `data[].status` | string: `enabled`, `disabled` | yes | | | `data[].disabledReason` | string: `merchant`, `gone`, `failing`, nullable | yes | Why a disabled endpoint stopped: `merchant` switched it off, the receiver answered `gone` (410), or it was `failing` for five days. | | `data[].secret` | string, nullable | | Only in the reply to create and rotate-secret: store it, it is never shown again. | | `data[].createdAt` | string (date-time) | yes | | | `hasMore` | boolean | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Retrieve a webhook endpoint `GET https://api.kosoor.com.sa/v1/webhook-endpoints/{id}` Needs webhooks:read. Only the key's own endpoints are found. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | #### Responses **200** OK (`application/json`) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | string | yes | | | `url` | string | yes | | | `events` | array of string | yes | | | `status` | string: `enabled`, `disabled` | yes | | | `disabledReason` | string: `merchant`, `gone`, `failing`, nullable | yes | Why a disabled endpoint stopped: `merchant` switched it off, the receiver answered `gone` (410), or it was `failing` for five days. | | `secret` | string, nullable | | Only in the reply to create and rotate-secret: store it, it is never shown again. | | `createdAt` | string (date-time) | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **404** resource_not_found: no such id among what the key may read. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Delete a webhook endpoint `DELETE https://api.kosoor.com.sa/v1/webhook-endpoints/{id}` Needs webhooks:write. Events still waiting for the endpoint are not sent. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Idempotency-Key` | string | yes | 1 to 255 printable ASCII characters, matched case-sensitively; a retry sends the same key. Kept 30 days. A key still in use answers 409 with Retry-After. Length 1–255. | | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | #### Responses **204** No Content Header `Idempotent-Replayed`: true when this is the stored reply of an earlier request with the same key. **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **404** resource_not_found: no such id among what the key may read. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **409** A conflict: error.code says which. idempotency_in_progress comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **422** Refused by a business rule or a reused Idempotency-Key: error.code says which. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Rotate a webhook endpoint's secret `POST https://api.kosoor.com.sa/v1/webhook-endpoints/{id}/rotate-secret` Needs webhooks:write. Replies with the new secret, shown only this once. For 24 hours every delivery carries a signature from each secret, so the receiver can switch without losing an event. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Idempotency-Key` | string | yes | 1 to 255 printable ASCII characters, matched case-sensitively; a retry sends the same key. Kept 30 days. A key still in use answers 409 with Retry-After. Length 1–255. | | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | #### Responses **200** OK (`application/json`) Header `Idempotent-Replayed`: true when this is the stored reply of an earlier request with the same key. | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | string | yes | | | `url` | string | yes | | | `events` | array of string | yes | | | `status` | string: `enabled`, `disabled` | yes | | | `disabledReason` | string: `merchant`, `gone`, `failing`, nullable | yes | Why a disabled endpoint stopped: `merchant` switched it off, the receiver answered `gone` (410), or it was `failing` for five days. | | `secret` | string, nullable | | Only in the reply to create and rotate-secret: store it, it is never shown again. | | `createdAt` | string (date-time) | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **404** resource_not_found: no such id among what the key may read. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **409** A conflict: error.code says which. idempotency_in_progress comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **422** Refused by a business rule or a reused Idempotency-Key: error.code says which. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md). --- ### Send a test event `POST https://api.kosoor.com.sa/v1/webhook-endpoints/{id}/test` Needs webhooks:write. Sends a webhookEndpoint.test event to the endpoint at once, signed and retried like any other. #### Headers | Name | Type | Required | Description | |---|---|---|---| | `Idempotency-Key` | string | yes | 1 to 255 printable ASCII characters, matched case-sensitively; a retry sends the same key. Kept 30 days. A key still in use answers 409 with Retry-After. Length 1–255. | | `Accept-Language` | string: `en-US`, `ar-SA` | | Choose the language (e.g., en, ar). Default `en-US`. | #### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | #### Responses **202** Accepted (`application/json`) Header `Idempotent-Replayed`: true when this is the stored reply of an earlier request with the same key. | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | string | yes | | | `type` | string | yes | | **400** The request is invalid: error.code names the problem and error.param the field. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **401** No usable API key: missing_api_key, invalid_api_key, revoked_api_key, expired_api_key. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **403** The key may not do this: tenant_suspended, missing_permission, branch_not_allowed. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **404** resource_not_found: no such id among what the key may read. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **409** A conflict: error.code says which. idempotency_in_progress comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **422** Refused by a business rule or a reused Idempotency-Key: error.code says which. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **429** rate_limited: the key used its allowance for the current window. Retry after the seconds in Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). **503** A test key's sandbox is not ready: sandbox_unavailable, sandbox_failed. sandbox_unavailable comes with Retry-After. (`application/json`) The error body; see [Error codes](https://docs.kosoor.com.sa/errors.md). Any call can also answer an error with the shared error body. Every code is listed in [Error codes](https://docs.kosoor.com.sa/errors.md).