Skip to main content

Record a sale

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 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:

EndpointPermissionGives you
GET /v1/branchesbranches:readthe branches your key may use: branchId
GET /v1/itemsitems:readthe items the merchant sells: lines[].itemId
GET /v1/payment-methodspayment_methods:readthe 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:

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 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.

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.

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:

{
"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.
  • 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.

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.

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:

{
"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.

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:

{
"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:

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:

{
"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 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​

ErrorWhat it meansWhat to do
422 customer_address_incompletea standard invoice needs the company's full address and VAT number or ID; missing lists the gapsfill them with PATCH /v1/customers/{id}, then send the invoice again
422 on_account_not_alloweda person was not paid in fullsend payment without amount
422 credit_limit_exceededthe company's account would pass its credit limittake payment at issue, or ask the merchant
422 customer_inactivethe merchant disabled the customerask the merchant
422 item_not_sellablethe item is a subscription, or not for saleuse another item
400 missing_parameter on lines[i].unitPricethe item is priced per car sizesend unitPrice
403 branch_not_allowedthe key is limited to other branchesuse a branch from GET /v1/branches
422 branch_not_onboardedthe branch is not registered with ZATCA, so it cannot issue a standard invoiceask the merchant
422 zatca_rejectedZATCA rejected a standard invoice; nothing was savedread zatcaErrors, fix the data, send it again under a new Idempotency-Key

Every code is in Error codes.