Skip to main content

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.

EndpointPermission
POST /v1/customerscustomers:write
GET /v1/customerscustomers: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:

NeededWhen
company.vatNumber, or company.idType with company.idValuealways
address.street, address.city, address.countryalways
address.buildingNumber, address.postalCode, address.districtwhen 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​

FieldRule
company.vatNumber15 digits that start and end with 3 (400 invalid_vat_number); no other customer may have it (409 vat_number_taken)
company.idType, company.idValuesent together, both set or both null; idValue is 1 to 50 letters and digits
address.buildingNumber4 digits when the country is SA; up to 20 characters elsewhere
address.postalCode5 digits when the country is SA; up to 20 characters elsewhere
company.creditLimit, company.creditPeriodDaysin 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:

idTypeNumber
CRNCommercial registration number
MOMLicence from the Ministry of Municipal and Rural Affairs
MLSLicence from the Ministry of Human Resources
700700 number
SAGInvestment licence (formerly SAGIA, now the Ministry of Investment)
NATNational ID
GCCGCC ID
IQAIqama (residence permit)
PASPassport
OTHAny other ID

Fields every customer takes​

FieldRule
typeperson or company; required on create and never changes
phoneE.164; no other customer may have it (409 phone_taken)
firstName, lastNamea person's name, up to 100 characters each
emaila valid address, up to 256 characters
referenceIdyour own id for the customer, up to 100 characters; no other customer may have it (409 reference_id_taken); set once
branchIdthe branch the customer belongs to; a branch your key may use (403 branch_not_allowed)
isActivetrue unless you send false; a disabled customer's invoices and payments answer 422 customer_inactive
metadataup 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:

ParameterMatches
phonethe phone, in E.164
vatNumbercompany.vatNumber
idValuecompany.idValue
referenceIdyour 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 sendResult
a field with a valuethe field takes it
email or phone as null, or a person's firstName or lastNamethe field is cleared; a person's phone cannot be cleared
metadatareplaces the whole map; {} or null empties it
typemust equal the current type; a customer never changes type
referenceIdaccepted once, while the customer has none; after that, a different value answers 400 invalid_parameter
company or address as null400 invalid_parameter: send the fields to change instead
isActive as null400 invalid_parameter: send true or false

A request that breaks any rule changes nothing.