Customers
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.
| 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.
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:
{
"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.
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:
{
"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:
{
"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.
curl "https://api.kosoor.com.sa/v1/customers?phone=%2B966501234567" \
-H "Authorization: Bearer ksr_test_..."
{
"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.
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.
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.